From a0c4a904e26995f751dd0a249e3acd5716146182 Mon Sep 17 00:00:00 2001 From: bsimpson63 Date: Tue, 7 May 2013 14:14:25 -0400 Subject: [PATCH] Sell campaigns by CPM. --- r2/example.ini | 1 + r2/r2/config/routing.py | 2 +- r2/r2/controllers/promotecontroller.py | 57 +++--- r2/r2/lib/app_globals.py | 1 + r2/r2/lib/errors.py | 5 +- r2/r2/lib/pages/pages.py | 20 ++- r2/r2/lib/promote.py | 117 ++++++++++++- r2/r2/models/promo.py | 20 ++- r2/r2/public/static/css/reddit.less | 32 +++- r2/r2/public/static/js/sponsored.js | 231 ++++++++++++++++++++++--- r2/r2/public/static/js/utils.js | 11 ++ r2/r2/templates/paymentform.html | 11 +- r2/r2/templates/promotelinkform.html | 108 ++++++++---- 13 files changed, 511 insertions(+), 105 deletions(-) diff --git a/r2/example.ini b/r2/example.ini index 9c0bd6400..b283c1d98 100644 --- a/r2/example.ini +++ b/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 = diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 4887035ad..2cd60901d 100644 --- a/r2/r2/config/routing.py +++ b/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', diff --git a/r2/r2/controllers/promotecontroller.py b/r2/r2/controllers/promotecontroller.py index 495a6c9e2..9e50515f2 100644 --- a/r2/r2/controllers/promotecontroller.py +++ b/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 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(), diff --git a/r2/r2/lib/app_globals.py b/r2/r2/lib/app_globals.py index a845f216c..63163142e 100755 --- a/r2/r2/lib/app_globals.py +++ b/r2/r2/lib/app_globals.py @@ -214,6 +214,7 @@ class Globals(object): config_gold_price: [ 'gold_month_price', 'gold_year_price', + 'cpm_selfserve', ], } diff --git a/r2/r2/lib/errors.py b/r2/r2/lib/errors.py index ec9b43e4e..47c5b2cba 100644 --- a/r2/r2/lib/errors.py +++ b/r2/r2/lib/errors.py @@ -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")), diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 7ec61b7c5..8a1f6c441 100755 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -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): diff --git a/r2/r2/lib/promote.py b/r2/r2/lib/promote.py index 7f908f8d8..5f75bd81d 100644 --- a/r2/r2/lib/promote.py +++ b/r2/r2/lib/promote.py @@ -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 diff --git a/r2/r2/models/promo.py b/r2/r2/models/promo.py index 1cf5b5756..3ecb469c3 100644 --- a/r2/r2/models/promo.py +++ b/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 @@ 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: diff --git a/r2/r2/public/static/css/reddit.less b/r2/r2/public/static/css/reddit.less index 312fcaa79..93469f0cd 100755 --- a/r2/r2/public/static/css/reddit.less +++ b/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; } diff --git a/r2/r2/public/static/js/sponsored.js b/r2/r2/public/static/js/sponsored.js index fac92d9e2..1668d3531 100644 --- a/r2/r2/public/static/js/sponsored.js +++ b/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 = $('