Skip to content
Browse files

Sell campaigns by CPM.

  • Loading branch information...
1 parent 0d7736a commit a0c4a904e26995f751dd0a249e3acd5716146182 @bsimpson63 bsimpson63 committed with bsimpson63 May 7, 2013
View
1 r2/example.ini
@@ -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
2 r2/r2/config/routing.py
@@ -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
57 r2/r2/controllers/promotecontroller.py
@@ -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.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.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 @@
VUrl,
)
from r2.models import (
+ calc_impressions,
Frontpage,
Link,
LiveAdWeights,
@@ -248,6 +251,15 @@ def GET_edit_promo_campaign(self, campaign):
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 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
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 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
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 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
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 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
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
1 r2/r2/lib/app_globals.py
@@ -214,6 +214,7 @@ class Globals(object):
config_gold_price: [
'gold_month_price',
'gold_year_price',
+ 'cpm_selfserve',
],
}
View
5 r2/r2/lib/errors.py
@@ -59,7 +59,7 @@
('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 @@
('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 @@
('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
20 r2/r2/lib/pages/pages.py
@@ -3339,6 +3339,8 @@ def setup(self, link, listing):
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 @@ def setup(self, link, listing):
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
117 r2/r2/lib/promote.py
@@ -65,6 +65,7 @@
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 @@ def create(cls, link, campaigns):
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 @@ def create(cls, link, campaigns):
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,13 +705,75 @@ 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:
raise Exception("Some scheduled campaigns could not be added to daily "
"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
20 r2/r2/models/promo.py
@@ -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 @@ def __getattr__(self, attr):
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,17 +106,26 @@ def _by_user(cls, account_id):
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
def is_live_now(self):
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
32 r2/r2/public/static/css/reddit.less
@@ -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
231 r2/r2/public/static/js/sponsored.js
@@ -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
11 r2/r2/public/static/js/utils.js
@@ -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
11 r2/r2/templates/paymentform.html
@@ -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 @@
<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 @@
</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
108 r2/r2/templates/promotelinkform.html
@@ -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.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 @@
<table class="preftable">
<tr>
- <th>duration</th>
+ <th>dates</th>
<td class="prefright">
<%
mindate = thing.startdate
@@ -278,14 +280,14 @@
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 @@
</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 @@
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 @@
<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 @@
%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 @@
<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 @@
<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

0 comments on commit a0c4a90

Please sign in to comment.
Something went wrong with that request. Please try again.