Skip to content
This repository
Browse code

Sell campaigns by CPM.

  • Loading branch information...
commit a0c4a904e26995f751dd0a249e3acd5716146182 1 parent 0d7736a
Brian Simpson bsimpson63 authored
1  r2/example.ini
@@ -315,6 +315,7 @@ allowed_pay_countries = United States, United Kingdom, Canada
315 315 sponsors =
316 316 selfserve_support_email = selfservesupport@mydomain.com
317 317 MAX_CAMPAIGNS_PER_LINK = 100
  318 +cpm_selfserve = 1.00
318 319
319 320 # authorize.net credentials (blank authorizenetapi to disable)
320 321 authorizenetapi =
2  r2/r2/config/routing.py
@@ -330,7 +330,7 @@ def make_map():
330 330 "freebie|promote_note|update_pay|refund|"
331 331 "traffic_viewer|rm_traffic_viewer|"
332 332 "edit_campaign|delete_campaign|meta_promo|"
333   - "add_roadblock|rm_roadblock")))
  333 + "add_roadblock|rm_roadblock|check_inventory")))
334 334 mc('/api/:action', controller='apiminimal',
335 335 requirements=dict(action="new_captcha"))
336 336 mc('/api/:type', controller='api',
57 r2/r2/controllers/promotecontroller.py
@@ -21,6 +21,7 @@
21 21 ###############################################################################
22 22 from datetime import datetime, timedelta
23 23
  24 +from babel.numbers import format_number
24 25 import itertools
25 26 import json
26 27 import urllib
@@ -29,7 +30,7 @@
29 30 from pylons.i18n import _
30 31
31 32 from r2.controllers.listingcontroller import ListingController
32   -from r2.lib import cssfilter, promote
  33 +from r2.lib import cssfilter, inventory, promote
33 34 from r2.lib.authorize import get_account_info, edit_profile, PROFILE_LIMIT
34 35 from r2.lib.db import queries
35 36 from r2.lib.errors import errors
@@ -52,8 +53,9 @@
52 53 from r2.lib.pages.trafficpages import TrafficViewerList
53 54 from r2.lib.pages.things import wrap_links
54 55 from r2.lib.system_messages import user_added_messages
55   -from r2.lib.utils import make_offset_date
  56 +from r2.lib.utils import make_offset_date, to_date
56 57 from r2.lib.validator import (
  58 + json_validate,
57 59 nop,
58 60 noresponse,
59 61 VAccountByName,
@@ -85,6 +87,7 @@
85 87 VUrl,
86 88 )
87 89 from r2.models import (
  90 + calc_impressions,
88 91 Frontpage,
89 92 Link,
90 93 LiveAdWeights,
@@ -248,6 +251,15 @@ def GET_edit_promo_campaign(self, campaign):
248 251 link = Link._byID(campaign.link_id)
249 252 return self.redirect(promote.promo_edit_url(link))
250 253
  254 + @json_validate(sr=VSubmitSR('sr', promotion=True),
  255 + start=VDate('startdate'),
  256 + end=VDate('enddate'))
  257 + def GET_check_inventory(self, responder, sr, start, end):
  258 + sr = sr or Frontpage
  259 + available_by_datestr = inventory.get_available_pageviews(sr, start, end,
  260 + datestr=True)
  261 + return {'inventory': available_by_datestr}
  262 +
251 263 @validate(VSponsor(),
252 264 dates=VDateRange(["startdate", "enddate"],
253 265 max_range=timedelta(days=28),
@@ -454,6 +466,7 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
454 466 return
455 467
456 468 start, end = dates or (None, None)
  469 + cpm = g.cpm_selfserve.pennies
457 470
458 471 if (start and end and not promote.is_accepted(l) and
459 472 not c.user_is_sponsor):
@@ -487,20 +500,9 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
487 500 form.has_errors('title', errors.TOO_MANY_CAMPAIGNS)
488 501 return
489 502
490   - duration = max((end - start).days, 1)
491   -
492 503 if form.has_errors('bid', errors.BAD_BID):
493 504 return
494 505
495   - # minimum bid depends on user privilege and targeting, checked here
496   - # instead of in the validator b/c current duration is needed
497   - if c.user_is_sponsor:
498   - min_daily_bid = 0
499   - elif targeting == 'one':
500   - min_daily_bid = g.min_promote_bid * 1.5
501   - else:
502   - min_daily_bid = g.min_promote_bid
503   -
504 506 if campaign_id36:
505 507 # you cannot edit the bid of a live ad unless it's a freebie
506 508 try:
@@ -514,10 +516,11 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
514 516 except NotFound:
515 517 pass
516 518
517   - if bid is None or bid / duration < min_daily_bid:
  519 + min_bid = 0 if c.user_is_sponsor else g.min_promote_bid
  520 + if bid is None or bid < min_bid:
518 521 c.errors.add(errors.BAD_BID, field='bid',
519   - msg_params={'min': min_daily_bid,
520   - 'max': g.max_promote_bid})
  522 + msg_params={'min': min_bid,
  523 + 'max': g.max_promote_bid})
521 524 form.has_errors('bid', errors.BAD_BID)
522 525 return
523 526
@@ -539,17 +542,31 @@ def POST_edit_campaign(self, form, jquery, l, campaign_id36,
539 542 if targeting == 'none':
540 543 sr = None
541 544
  545 + # Check inventory
  546 + ndays = (to_date(end) - to_date(start)).days
  547 + total_request = calc_impressions(bid, cpm)
  548 + daily_request = int(total_request / ndays)
  549 + oversold = inventory.get_oversold(sr or Frontpage, start, end,
  550 + daily_request)
  551 + if oversold:
  552 + msg_params = {'daily_request': format_number(daily_request,
  553 + locale=c.locale)}
  554 + c.errors.add(errors.OVERSOLD_DETAIL, field='bid',
  555 + msg_params=msg_params)
  556 + form.has_errors('bid', errors.OVERSOLD_DETAIL)
  557 + return
  558 +
542 559 if campaign_id36 is not None:
543 560 campaign = PromoCampaign._byID36(campaign_id36)
544   - promote.edit_campaign(l, campaign, dates, bid, sr)
  561 + promote.edit_campaign(l, campaign, dates, bid, cpm, sr)
545 562 r = promote.get_renderable_campaigns(l, campaign)
546 563 jquery.update_campaign(r.campaign_id36, r.start_date, r.end_date,
547   - r.duration, r.bid, r.sr, r.status)
  564 + r.duration, r.bid, r.cpm, r.sr, r.status)
548 565 else:
549   - campaign = promote.new_campaign(l, dates, bid, sr)
  566 + campaign = promote.new_campaign(l, dates, bid, cpm, sr)
550 567 r = promote.get_renderable_campaigns(l, campaign)
551 568 jquery.new_campaign(r.campaign_id36, r.start_date, r.end_date,
552   - r.duration, r.bid, r.sr, r.status)
  569 + r.duration, r.bid, r.cpm, r.sr, r.status)
553 570
554 571 @validatedForm(VSponsor('link_id'),
555 572 VModhash(),
1  r2/r2/lib/app_globals.py
@@ -214,6 +214,7 @@ class Globals(object):
214 214 config_gold_price: [
215 215 'gold_month_price',
216 216 'gold_year_price',
  217 + 'cpm_selfserve',
217 218 ],
218 219 }
219 220
5 r2/r2/lib/errors.py
@@ -59,7 +59,7 @@
59 59 ('INVALID_PREF', "that preference isn't valid"),
60 60 ('BAD_NUMBER', _("that number isn't in the right range (%(range)s)")),
61 61 ('BAD_STRING', _("you used a character here that we can't handle")),
62   - ('BAD_BID', _("your bid must be at least $%(min)d per day and no more than to $%(max)d in total.")),
  62 + ('BAD_BID', _("your budget must be at least $%(min)d and no more than $%(max)d.")),
63 63 ('ALREADY_SUB', _("that link has already been submitted")),
64 64 ('SUBREDDIT_EXISTS', _('that subreddit already exists')),
65 65 ('SUBREDDIT_NOEXIST', _('that subreddit doesn\'t exist')),
@@ -79,6 +79,7 @@
79 79 ('NO_EMAILS', _('please enter at least one email address')),
80 80 ('TOO_MANY_EMAILS', _('please only share to %(num)s emails at a time.')),
81 81 ('OVERSOLD', _('that subreddit has already been oversold on %(start)s to %(end)s. Please pick another subreddit or date.')),
  82 + ('OVERSOLD_DETAIL', _('We have insufficient inventory to fulfill your requested budget, target, and dates. Requested %(daily_request)s impressions per day.')),
82 83 ('BAD_DATE', _('please provide a date of the form mm/dd/yyyy')),
83 84 ('BAD_DATE_RANGE', _('the dates need to be in order and not identical')),
84 85 ('DATE_RANGE_TOO_LARGE', _('you must choose a date range of less than %(days)s days')),
@@ -115,7 +116,7 @@
115 116 ('BAD_HASH', _("i don't believe you.")),
116 117 ('ALREADY_MODERATOR', _('that user is already a moderator')),
117 118 ('NO_INVITE_FOUND', _('there is no pending invite for that subreddit')),
118   - ('BID_LIVE', _('you cannot edit the bid of a live ad')),
  119 + ('BID_LIVE', _('you cannot edit the budget of a live ad')),
119 120 ('TOO_MANY_CAMPAIGNS', _('you have too many campaigns for that promotion')),
120 121 ('BAD_JSONP_CALLBACK', _('that jsonp callback contains invalid characters')),
121 122 ('INVALID_PERMISSION_TYPE', _("permissions don't apply to that type of user")),
20 r2/r2/lib/pages/pages.py
@@ -3339,6 +3339,8 @@ def setup(self, link, listing):
3339 3339 pay_id=bid.pay_id,
3340 3340 amount_str=format_currency(bid.bid, 'USD',
3341 3341 locale=c.locale),
  3342 + charge_str=format_currency(bid.charge or bid.bid, 'USD',
  3343 + locale=c.locale),
3342 3344 )
3343 3345 self.bids.append(row)
3344 3346
@@ -3359,13 +3361,29 @@ def setup(self, link, listing):
3359 3361
3360 3362 self.mindate = mindate.strftime("%m/%d/%Y")
3361 3363
  3364 + self.subreddit_selector = SubredditSelector()
  3365 +
  3366 + # preload some inventory
  3367 + srnames = []
  3368 + for title, names in self.subreddit_selector.subreddit_names:
  3369 + srnames.extend(names)
  3370 + srs = Subreddit._by_name(srnames).values()
  3371 + srs.append(Frontpage)
  3372 + inv_start = startdate
  3373 + inv_end = startdate + datetime.timedelta(days=14)
  3374 + sr_inventory = inventory.get_available_pageviews(srs, inv_start,
  3375 + inv_end, datestr=True)
  3376 + sr_inventory[''] = sr_inventory[Frontpage.name]
  3377 + del sr_inventory[Frontpage.name]
  3378 + self.inventory = sr_inventory
  3379 +
3362 3380 self.link = promote.wrap_promoted(link)
3363 3381 self.listing = listing
3364 3382 campaigns = PromoCampaign._by_link(link._id)
3365 3383 self.campaigns = promote.get_renderable_campaigns(link, campaigns)
3366 3384 self.promotion_log = PromotionLog.get(link)
3367 3385
3368   - self.min_daily_bid = 0 if c.user_is_admin else g.min_promote_bid
  3386 + self.min_bid = 0 if c.user_is_sponsor else g.min_promote_bid
3369 3387
3370 3388
3371 3389 class PromoAdminTool(Reddit):
117 r2/r2/lib/promote.py
@@ -65,6 +65,7 @@
65 65 PromotionLog,
66 66 PromotionWeights,
67 67 Subreddit,
  68 + traffic,
68 69 )
69 70 from r2.models.keyvalue import NamedGlobals
70 71
@@ -172,13 +173,14 @@ def campaign_is_live(link, campaign_index):
172 173 # control functions
173 174
174 175 class RenderableCampaign():
175   - def __init__(self, campaign_id36, start_date, end_date, duration, bid, sr,
176   - status):
  176 + def __init__(self, campaign_id36, start_date, end_date, duration, bid,
  177 + cpm, sr, status):
177 178 self.campaign_id36 = campaign_id36
178 179 self.start_date = start_date
179 180 self.end_date = end_date
180 181 self.duration = duration
181 182 self.bid = bid
  183 + self.cpm = cpm
182 184 self.sr = sr
183 185 self.status = status
184 186
@@ -197,6 +199,7 @@ def create(cls, link, campaigns):
197 199 duration = strings.time_label % dict(num=ndays,
198 200 time=ungettext("day", "days", ndays))
199 201 bid = "%.2f" % camp.bid
  202 + cpm = getattr(camp, 'cpm', g.cpm_selfserve.pennies)
200 203 sr = camp.sr_name
201 204 status = {'paid': bool(transaction),
202 205 'complete': False,
@@ -212,8 +215,8 @@ def create(cls, link, campaigns):
212 215 elif transaction.is_charged() or transaction.is_refund():
213 216 status['complete'] = True
214 217
215   - rc = cls(campaign_id36, start_date, end_date, duration, bid, sr,
216   - status)
  218 + rc = cls(campaign_id36, start_date, end_date, duration, bid,
  219 + cpm, sr, status)
217 220 r.append(rc)
218 221 return r
219 222
@@ -320,10 +323,10 @@ def get_transactions(link, campaigns):
320 323 bids_by_campaign = {c._id: bid_dict[(c._id, c.trans_id)] for c in campaigns}
321 324 return bids_by_campaign
322 325
323   -def new_campaign(link, dates, bid, sr):
  326 +def new_campaign(link, dates, bid, cpm, sr):
324 327 # empty string for sr_name means target to all
325 328 sr_name = sr.name if sr else ""
326   - campaign = PromoCampaign._new(link, sr_name, bid, dates[0], dates[1])
  329 + campaign = PromoCampaign._new(link, sr_name, bid, cpm, dates[0], dates[1])
327 330 PromotionWeights.add(link, campaign._id, sr_name, dates[0], dates[1], bid)
328 331 PromotionLog.add(link, 'campaign %s created' % campaign._id)
329 332 author = Account._byID(link.author_id, True)
@@ -334,7 +337,7 @@ def new_campaign(link, dates, bid, sr):
334 337 def free_campaign(link, campaign, user):
335 338 auth_campaign(link, campaign, user, -1)
336 339
337   -def edit_campaign(link, campaign, dates, bid, sr):
  340 +def edit_campaign(link, campaign, dates, bid, cpm, sr):
338 341 sr_name = sr.name if sr else '' # empty string means target to all
339 342 try:
340 343 # if the bid amount changed, cancel any pending transactions
@@ -346,7 +349,8 @@ def edit_campaign(link, campaign, dates, bid, sr):
346 349 dates[0], dates[1], bid)
347 350
348 351 # update values in the db
349   - campaign.update(dates[0], dates[1], bid, sr_name, campaign.trans_id, commit=True)
  352 + campaign.update(dates[0], dates[1], bid, cpm, sr_name,
  353 + campaign.trans_id, commit=True)
350 354
351 355 # record the transaction
352 356 text = 'updated campaign %s. (bid: %0.2f)' % (campaign._id, bid)
@@ -701,6 +705,8 @@ def make_daily_promotions(offset=0, test=False):
701 705 else:
702 706 print by_srid
703 707
  708 + finalize_completed_campaigns(daysago=offset+1)
  709 +
704 710 # after launching as many campaigns as possible, raise an exception to
705 711 # report any error campaigns. (useful for triggering alerts in irc)
706 712 if error_campaigns:
@@ -708,6 +714,66 @@ def make_daily_promotions(offset=0, test=False):
708 714 "promotions: %r" % error_campaigns)
709 715
710 716
  717 +def finalize_completed_campaigns(daysago=1):
  718 + # PromoCampaign.end_date is utc datetime with year, month, day only
  719 + now = datetime.now(g.tz)
  720 + date = now - timedelta(days=daysago)
  721 + date = date.replace(hour=0, minute=0, second=0, microsecond=0)
  722 +
  723 + q = PromoCampaign._query(PromoCampaign.c.end_date == date,
  724 + # exclude no transaction and freebies
  725 + PromoCampaign.c.trans_id > 0,
  726 + data=True)
  727 + campaigns = list(q)
  728 +
  729 + if not campaigns:
  730 + return
  731 +
  732 + # check that traffic is up to date
  733 + earliest_campaign = min(campaigns, key=lambda camp: camp.start_date)
  734 + start, end = get_total_run(earliest_campaign)
  735 + missing_traffic = traffic.get_missing_traffic(start.replace(tzinfo=None),
  736 + date.replace(tzinfo=None))
  737 + if missing_traffic:
  738 + raise ValueError("Can't finalize campaigns finished on %s."
  739 + "Missing traffic from %s" % (date, missing_traffic))
  740 +
  741 + links = Link._byID([camp.link_id for link in links], data=True)
  742 +
  743 + for camp in campaigns:
  744 + if hasattr(camp, 'refund_amount'):
  745 + continue
  746 +
  747 + link = links[camp.link_id]
  748 + billable_impressions = get_billable_impressions(camp)
  749 + billable_amount = get_billable_amount(camp, billable_impressions)
  750 +
  751 + if billable_amount >= camp.bid:
  752 + text = ('%s completed with $%s billable (%s impressions @ $%s).'
  753 + % (camp, billable_amount, billable_impressions, camp.cpm))
  754 + PromotionLog.add(link, text)
  755 + refund_amount = 0.
  756 + else:
  757 + refund_amount = camp.bid - billable_amount
  758 + user = Account._byID(link.author_id, data=True)
  759 + try:
  760 + success = authorize.refund_transaction(user, camp.trans_id,
  761 + camp._id, refund_amount)
  762 + except authorize.AuthorizeNetException as e:
  763 + text = ('%s $%s refund failed' % (camp, refund_amount))
  764 + PromotionLog.add(link, text)
  765 + g.log.debug(text + ' (response: %s)' % e)
  766 + continue
  767 + text = ('%s completed with $%s billable (%s impressions @ $%s).'
  768 + ' %s refunded.' % (camp, billable_amount,
  769 + billable_impressions, camp.cpm,
  770 + refund_amount))
  771 + PromotionLog.add(link, text)
  772 +
  773 + camp.refund_amount = refund_amount
  774 + camp._commit()
  775 +
  776 +
711 777 PromoTuple = namedtuple('PromoTuple', ['link', 'weight', 'campaign'])
712 778
713 779
@@ -809,6 +875,41 @@ def get_traffic_dates(thing):
809 875 return start, end
810 876
811 877
  878 +def get_billable_impressions(campaign):
  879 + start, end = get_traffic_dates(campaign)
  880 + if start > datetime.now(g.tz):
  881 + return 0
  882 +
  883 + traffic_lookup = traffic.TargetedImpressionsByCodename.promotion_history
  884 + imps = traffic_lookup(campaign._fullname, start.replace(tzinfo=None),
  885 + end.replace(tzinfo=None))
  886 + billable_impressions = sum(imp for date, (imp,) in imps)
  887 + return billable_impressions
  888 +
  889 +
  890 +def get_billable_amount(camp, impressions):
  891 + if hasattr(camp, 'cpm'):
  892 + value_delivered = impressions / 1000. * camp.cpm / 100.
  893 + billable_amount = min(camp.bid, value_delivered)
  894 + else:
  895 + # pre-CPM campaigns are charged in full regardless of impressions
  896 + billable_amount = camp.bid
  897 + return billable_amount
  898 +
  899 +
  900 +def get_spent_amount(campaign):
  901 + if hasattr(campaign, 'refund_amount'):
  902 + # no need to calculate spend if we've already refunded
  903 + spent = campaign.bid - campaign.refund_amount
  904 + elif not hasattr(campaign, 'cpm'):
  905 + # pre-CPM campaign
  906 + return campaign.bid
  907 + else:
  908 + billable_impressions = get_billable_impressions(campaign)
  909 + spent = get_billable_amount(campaign, billable_impressions)
  910 + return spent
  911 +
  912 +
812 913 def Run(offset=0, verbose=True):
813 914 """reddit-job-update_promos: Intended to be run hourly to pull in
814 915 scheduled changes to ads
20 r2/r2/models/promo.py
@@ -55,6 +55,12 @@ def get_promote_srid(name = 'promos'):
55 55 return sr._id
56 56
57 57
  58 +def calc_impressions(bid, cpm_pennies):
  59 + # bid is in dollars, cpm_pennies is pennies
  60 + # CPM is cost per 1000 impressions
  61 + return int(bid / cpm_pennies * 1000 * 100)
  62 +
  63 +
58 64 NO_TRANSACTION = 0
59 65
60 66 class PromoCampaign(Thing):
@@ -67,10 +73,11 @@ def __getattr__(self, attr):
67 73 return val
68 74
69 75 @classmethod
70   - def _new(cls, link, sr_name, bid, start_date, end_date):
  76 + def _new(cls, link, sr_name, bid, cpm, start_date, end_date):
71 77 pc = PromoCampaign(link_id=link._id,
72 78 sr_name=sr_name,
73 79 bid=bid,
  80 + cpm=cpm,
74 81 start_date=start_date,
75 82 end_date=end_date,
76 83 trans_id=NO_TRANSACTION,
@@ -99,6 +106,13 @@ def _by_user(cls, account_id):
99 106 def ndays(self):
100 107 return (self.end_date - self.start_date).days
101 108
  109 + @property
  110 + def impressions(self):
  111 + # deal with pre-CPM PromoCampaigns
  112 + if not hasattr(self, 'cpm'):
  113 + return -1
  114 + return calc_impressions(self.bid, self.cpm)
  115 +
102 116 def is_freebie(self):
103 117 return self.trans_id < 0
104 118
@@ -106,10 +120,12 @@ def is_live_now(self):
106 120 now = datetime.now(g.tz)
107 121 return self.start_date < now and self.end_date > now
108 122
109   - def update(self, start_date, end_date, bid, sr_name, trans_id, commit=True):
  123 + def update(self, start_date, end_date, bid, cpm, sr_name, trans_id,
  124 + commit=True):
110 125 self.start_date = start_date
111 126 self.end_date = end_date
112 127 self.bid = bid
  128 + self.cpm = cpm
113 129 self.sr_name = sr_name
114 130 self.trans_id = trans_id
115 131 if commit:
32 r2/r2/public/static/css/reddit.less
@@ -4303,12 +4303,15 @@ ul.tabmenu.formtab {
4303 4303 border-bottom: none;
4304 4304 }
4305 4305
4306   -.targeting > ul {
  4306 +.campaign .notes ul {
4307 4307 font-size: x-small;
4308 4308 list-style-type: disc;
4309 4309 margin: 0 20px 10px;
4310 4310 }
4311   -.existing-campaigns td > button { margin: 0px 5px 0px 0px; }
  4311 +.existing-campaigns td > button {
  4312 + margin: 2px;
  4313 + padding: 2px 4px;
  4314 +}
4312 4315
4313 4316 .campaign .bid-info { font-size: x-small; }
4314 4317 .campaign .bid-info.error { color: red; }
@@ -4319,7 +4322,7 @@ ul.tabmenu.formtab {
4319 4322 .campaign #bid {
4320 4323 text-align: right;
4321 4324 }
4322   -.campaign .targeting {
  4325 +.campaign .targeting, .campaign .notes {
4323 4326 margin-left: 25px;
4324 4327 }
4325 4328 .campaign .targeting input{
@@ -4346,6 +4349,13 @@ ul.tabmenu.formtab {
4346 4349 margin: 5px;
4347 4350 }
4348 4351
  4352 +#campaign td,
  4353 +#campaign span,
  4354 +#campaign label,
  4355 +#campaign li {
  4356 + font-size: small;
  4357 +}
  4358 +
4349 4359 /***traffic stuff***/
4350 4360 .traffic-table,
4351 4361 .traffic-tables-side fieldset {
@@ -4961,6 +4971,10 @@ table.lined-table {
4961 4971 font-weight: bold;
4962 4972 }
4963 4973
  4974 +div #campaign-field {
  4975 + width: auto;
  4976 +}
  4977 +
4964 4978 .create-promotion .help {
4965 4979 font-size: x-small;
4966 4980 }
@@ -4977,7 +4991,12 @@ table.lined-table {
4977 4991 }
4978 4992
4979 4993
4980   -.create-promo { float: left; width: 520px; margin-right: 20px;}
  4994 +.create-promo {
  4995 + float: left;
  4996 + width: 570px;
  4997 + margin-right: 20px;
  4998 +}
  4999 +
4981 5000 .create-promo .infobar {
4982 5001 margin-right: 0;
4983 5002 border-color: red;
@@ -4992,6 +5011,11 @@ table.lined-table {
4992 5011 }
4993 5012 .create-promo .rules { float: left; margin-left: 15px; }
4994 5013
  5014 +.create-promo textarea,
  5015 +.create-promo input[type=text] {
  5016 + width: 98%;
  5017 +}
  5018 +
4995 5019 .fancy-settings h1, .create-promotion h1 { font-size: 200%; color: #999; margin:10px 5px; }
4996 5020 .fancy-settings h2 { font-size: 200%; font-weight:normal; color: #999; margin:10px 5px; }
4997 5021 .fancy-settings h1 strong { font-weight:bold; color: #666; }
231 r2/r2/public/static/js/sponsored.js
@@ -2,6 +2,199 @@ function update_box(elem) {
2 2 $(elem).prevAll('*[type="checkbox"]:first').prop('checked', true);
3 3 };
4 4
  5 +r.sponsored = {
  6 + init: function() {
  7 + $("#sr-autocomplete").on("sr-changed blur", function() {
  8 + r.sponsored.fill_campaign_editor()
  9 + })
  10 +
  11 + this.inventory = {}
  12 + },
  13 +
  14 + setup: function(inventory_by_sr) {
  15 + this.inventory = inventory_by_sr
  16 + },
  17 +
  18 + get_dates: function(startdate, enddate) {
  19 + var start = $.datepicker.parseDate('mm/dd/yy', startdate),
  20 + end = $.datepicker.parseDate('mm/dd/yy', enddate),
  21 + ndays = (end - start) / (1000 * 60 * 60 * 24),
  22 + dates = []
  23 +
  24 + for (var i=0; i < ndays; i++) {
  25 + var d = new Date(start.getTime())
  26 + d.setDate(start.getDate() + i)
  27 + dates.push(d)
  28 + }
  29 + return dates
  30 + },
  31 +
  32 + get_check_inventory: function(srname, dates) {
  33 + var fetch = _.some(dates, function(date) {
  34 + var datestr = $.datepicker.formatDate('mm/dd/yy', date)
  35 + if (!(this.inventory[srname] && this.inventory[srname][datestr])) {
  36 + r.debug('need to fetch ' + datestr + ' for ' + srname)
  37 + return true
  38 + }
  39 + }, this)
  40 +
  41 + if (fetch) {
  42 + dates.sort(function(d1,d2){return d1 - d2})
  43 + var end = new Date(dates[dates.length-1].getTime())
  44 + end.setDate(end.getDate() + 5)
  45 +
  46 + return $.ajax({
  47 + type: 'GET',
  48 + url: '/api/check_inventory.json',
  49 + data: {
  50 + sr: srname,
  51 + startdate: $.datepicker.formatDate('mm/dd/yy', dates[0]),
  52 + enddate: $.datepicker.formatDate('mm/dd/yy', end)
  53 + },
  54 + success: function(data) {
  55 + if (!r.sponsored.inventory[srname]) {
  56 + r.sponsored.inventory[srname] = {}
  57 + }
  58 +
  59 + for (var datestr in data.inventory) {
  60 + r.sponsored.inventory[srname][datestr] = data.inventory[datestr]
  61 + }
  62 + }
  63 + })
  64 + } else {
  65 + return true
  66 + }
  67 + },
  68 +
  69 + check_inventory: function($form) {
  70 + var bid = this.get_bid($form),
  71 + cpm = this.get_cpm($form),
  72 + requested = this.calc_impressions(bid, cpm),
  73 + startdate = $form.find('*[name="startdate"]').val(),
  74 + enddate = $form.find('*[name="enddate"]').val(),
  75 + ndays = this.get_duration($form),
  76 + daily_request = Math.floor(requested / ndays),
  77 + targeted = $form.find('#targeting').is(':checked'),
  78 + target = $form.find('*[name="sr"]').val(),
  79 + srname = targeted ? target : '',
  80 + dates = r.sponsored.get_dates(startdate, enddate)
  81 +
  82 + $.when(r.sponsored.get_check_inventory(srname, dates)).done(
  83 + function() {
  84 + var oversold = {}
  85 +
  86 + _.each(dates, function(date) {
  87 + var datestr = $.datepicker.formatDate('mm/dd/yy', date),
  88 + available = r.sponsored.inventory[srname][datestr]
  89 + if (available < daily_request) {
  90 + oversold[datestr] = available
  91 + }
  92 + })
  93 +
  94 + if (!_.isEmpty(oversold)) {
  95 + var oversold_dates = _.keys(oversold)
  96 +
  97 + var message = r._("We have insufficient inventory to fulfill" +
  98 + " your requested budget, target, and dates." +
  99 + " Requested %(daily_request)s impressions " +
  100 + "per day."
  101 + ).format({daily_request: r.utils.prettyNumber(daily_request)})
  102 +
  103 + $(".OVERSOLD_DETAIL").text(message).show()
  104 + var available_list = $('<ul>').appendTo(".OVERSOLD_DETAIL")
  105 + _.each(oversold, function(num, datestr) {
  106 + var available_msg = r._("%(num)s available on %(date)s").format({
  107 + num: r.utils.prettyNumber(num),
  108 + date: datestr
  109 + })
  110 + available_list.append($('<li>').text(available_msg))
  111 + })
  112 +
  113 + r.sponsored.disable_form($form)
  114 + } else {
  115 + $(".OVERSOLD_DETAIL").hide()
  116 + r.sponsored.enable_form($form)
  117 + }
  118 + }
  119 + )
  120 + },
  121 +
  122 + get_duration: function($form) {
  123 + return Math.round((Date.parse($form.find('*[name="enddate"]').val()) -
  124 + Date.parse($form.find('*[name="startdate"]').val())) / (86400*1000))
  125 + },
  126 +
  127 + get_bid: function($form) {
  128 + return parseFloat($form.find('*[name="bid"]').val())
  129 + },
  130 +
  131 + get_cpm: function($form) {
  132 + return parseInt($form.find('*[name="cpm"]').val())
  133 + },
  134 +
  135 + on_date_change: function() {
  136 + this.fill_campaign_editor()
  137 + },
  138 +
  139 + on_bid_change: function() {
  140 + this.fill_campaign_editor()
  141 + },
  142 +
  143 + fill_campaign_editor: function() {
  144 + var $form = $("#campaign"),
  145 + bid = this.get_bid($form),
  146 + cpm = this.get_cpm($form),
  147 + ndays = this.get_duration($form),
  148 + impressions = this.calc_impressions(bid, cpm);
  149 +
  150 + $(".duration").text(ndays + " " + ((ndays > 1) ? r._("days") : r._("day")))
  151 + $(".impression-info").text(r._("%(num)s impressions").format({num: r.utils.prettyNumber(impressions)}))
  152 + $(".price-info").text(r._("$%(cpm)s per 1,000 impressions").format({cpm: (cpm/100).toFixed(2)}))
  153 +
  154 + this.check_bid($form)
  155 + this.check_inventory($form)
  156 + },
  157 +
  158 + disable_form: function($form) {
  159 + $form.find('button[name="create"], button[name="save"]')
  160 + .prop("disabled", "disabled")
  161 + .addClass("disabled");
  162 + },
  163 +
  164 + enable_form: function($form) {
  165 + $form.find('button[name="create"], button[name="save"]')
  166 + .removeProp("disabled")
  167 + .removeClass("disabled");
  168 + },
  169 +
  170 + targeting_on: function() {
  171 + $('.targeting').find('*[name="sr"]').prop("disabled", "").end().slideDown();
  172 + this.fill_campaign_editor()
  173 + },
  174 +
  175 + targeting_off: function() {
  176 + $('.targeting').find('*[name="sr"]').prop("disabled", "disabled").end().slideUp();
  177 + this.fill_campaign_editor()
  178 + },
  179 +
  180 + check_bid: function($form) {
  181 + var bid = this.get_bid($form),
  182 + minimum_bid = $("#bid").data("min_bid");
  183 +
  184 + $(".minimum-spend").removeClass("error");
  185 + if (bid < minimum_bid) {
  186 + $(".minimum-spend").addClass("error");
  187 + this.disable_form($form)
  188 + } else {
  189 + this.enable_form($form)
  190 + }
  191 + },
  192 +
  193 + calc_impressions: function(bid, cpm_pennies) {
  194 + return bid / cpm_pennies * 1000 * 100
  195 + }
  196 +}
  197 +
5 198 function update_bid(elem) {
6 199 var form = $(elem).parents(".campaign");
7 200 var is_targeted = $("#targeting").prop("checked");
@@ -98,19 +291,6 @@ function check_enddate(startdate, enddate) {
98 291 $("#datepicker-" + enddate.attr("id")).datepicker("destroy");
99 292 }
100 293
101   -function targeting_on(elem) {
102   - $(elem).parents(".campaign").find(".targeting")
103   - .find('*[name="sr"]').prop("disabled", "").end().slideDown();
104   -
105   - update_bid(elem);
106   -}
107   -
108   -function targeting_off(elem) {
109   - $(elem).parents(".campaign").find(".targeting")
110   - .find('*[name="sr"]').prop("disabled", "disabled").end().slideUp();
111   -
112   - update_bid(elem);
113   -}
114 294
115 295 (function($) {
116 296
@@ -134,14 +314,15 @@ function get_flag_class(flags) {
134 314 return css_class
135 315 }
136 316
137   -$.new_campaign = function(campaign_id36, start_date, end_date, duration,
138   - bid, targeting, flags) {
  317 +$.new_campaign = function(campaign_id36, start_date, end_date, duration,
  318 + bid, cpm, targeting, flags) {
139 319 cancel_edit(function() {
140 320 var data =('<input type="hidden" name="startdate" value="' +
141 321 start_date +'"/>' +
142 322 '<input type="hidden" name="enddate" value="' +
143 323 end_date + '"/>' +
144 324 '<input type="hidden" name="bid" value="' + bid + '"/>' +
  325 + '<input type="hidden" name="cpm" value="' + cpm + '"/>' +
145 326 '<input type="hidden" name="targeting" value="' +
146 327 (targeting || '') + '"/>' +
147 328 '<input type="hidden" name="campaign_id36" value="' + campaign_id36 + '"/>');
@@ -165,8 +346,8 @@ $.new_campaign = function(campaign_id36, start_date, end_date, duration,
165 346 return $;
166 347 };
167 348
168   -$.update_campaign = function(campaign_id36, start_date, end_date,
169   - duration, bid, targeting, flags) {
  349 +$.update_campaign = function(campaign_id36, start_date, end_date,
  350 + duration, bid, cpm, targeting, flags) {
170 351 cancel_edit(function() {
171 352 $('.existing-campaigns input[name="campaign_id36"]')
172 353 .filter('*[value="' + (campaign_id36 || '0') + '"]')
@@ -182,6 +363,7 @@ $.update_campaign = function(campaign_id36, start_date, end_date,
182 363 .find('*[name="enddate"]').val(end_date).end()
183 364 .find('*[name="targeting"]').val(targeting).end()
184 365 .find('*[name="bid"]').val(bid).end()
  366 + .find('*[name="cpm"]').val(cpm).end()
185 367 .find("button, span").remove();
186 368 $.set_up_campaigns();
187 369 });
@@ -199,10 +381,9 @@ $.set_up_campaigns = function() {
199 381 var td = $(this).find("td:last");
200 382 var bid_td = $(this).find("td:first").next().next().next()
201 383 .addClass("bid");
202   - var target_td = $(this).find("td:nth-child(5)")
203 384 if(td.length && ! td.children("button, span").length ) {
204 385 if(tr.hasClass("live")) {
205   - $(target_td).append($(view).addClass("view")
  386 + $(td).append($(view).addClass("view fancybutton")
206 387 .click(function() { view_campaign(tr) }));
207 388 }
208 389 /* once paid, we shouldn't muck around with the campaign */
@@ -313,11 +494,11 @@ function edit_campaign(elem) {
313 494 "css_class": "", "cells": [""]}],
314 495 tr.rowIndex + 1);
315 496 $("#edit-campaign-tr").children('td:first')
316   - .attr("colspan", 6).append(campaign).end()
  497 + .attr("colspan", 7).append(campaign).end()
317 498 .prev().fadeOut(function() {
318 499 var data_tr = $(this);
319 500 var c = $("#campaign");
320   - $.map(['startdate', 'enddate', 'bid', 'campaign_id36'],
  501 + $.map(['startdate', 'enddate', 'bid', 'cpm', 'campaign_id36'],
321 502 function(i) {
322 503 i = '*[name="' + i + '"]';
323 504 c.find(i).val(data_tr.find(i).val());
@@ -343,7 +524,7 @@ function edit_campaign(elem) {
343 524 init_enddate();
344 525 c.find('button[name="save"]').show().end()
345 526 .find('button[name="create"]').hide().end();
346   - update_bid('*[name="bid"]');
  527 + r.sponsored.fill_campaign_editor();
347 528 c.fadeIn();
348 529 } );
349 530 }
@@ -363,11 +544,12 @@ function check_number_of_campaigns(){
363 544 }
364 545 }
365 546
366   -function create_campaign(elem) {
  547 +function create_campaign() {
367 548 if (check_number_of_campaigns()){
368 549 return;
369 550 }
370 551 cancel_edit(function() {;
  552 + var base_cpm = $("#bid").data("base_cpm")
371 553 init_startdate();
372 554 init_enddate();
373 555 $("#campaign")
@@ -379,8 +561,9 @@ function create_campaign(elem) {
379 561 .prop("checked", "checked").end()
380 562 .find(".targeting").hide().end()
381 563 .find('*[name="sr"]').val("").prop("disabled", "disabled").end()
  564 + .find('input[name="cpm"]').val(base_cpm).end()
382 565 .fadeIn();
383   - update_bid('*[name="bid"]');
  566 + r.sponsored.fill_campaign_editor();
384 567 });
385 568 }
386 569
11 r2/r2/public/static/js/utils.js
@@ -81,7 +81,18 @@ r.utils = {
81 81 return _.escape(str).replace(this._mdLinkRe, function(match, text, url) {
82 82 return '<a href="' + url + '">' + text + '</a>'
83 83 })
  84 + },
  85 +
  86 + prettyNumber: function(number) {
  87 + // Add commas to separate every third digit
  88 + var numberAsInt = parseInt(number)
  89 + if (numberAsInt) {
  90 + return numberAsInt.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
  91 + } else {
  92 + return number
  93 + }
84 94 }
  95 +
85 96 }
86 97
87 98 // Nothing is true. Everything is permitted.
11 r2/r2/templates/paymentform.html
@@ -21,6 +21,7 @@
21 21 ###############################################################################
22 22
23 23 <%!
  24 + from babel.numbers import format_currency
24 25 from r2.lib.template_helpers import static
25 26 from r2.lib import js
26 27 %>
@@ -43,11 +44,8 @@
43 44 <p id="bid-field">
44 45 <input type="hidden" name="campaign" value="${thing.campaign.campaign_id36}" />
45 46 <input type="hidden" name="link" value="${thing.link._fullname}" />
46   - ${unsafe(_("Your current bid is $%(bid)s") % dict(bid=thing.campaign.bid))}
47   - ${error_field("BAD_BID", "bid")}
48   - <span class="gray">
49   - &#32;${_('(total for the duration provided)')}
50   - </span>
  47 + <% budget=format_currency(float(thing.campaign.bid), 'USD', locale=c.locale) %>
  48 + ${unsafe(_("Your current budget is %(budget)s") % dict(budget=budget))}
51 49 </p>
52 50 %if thing.profiles:
53 51 <p>
@@ -72,7 +70,8 @@
72 70 </p>
73 71 %endif
74 72 <p class="info">
75   - ${_("NOTE: your card will not be charged until the link has been queued for promotion.")}
  73 + ${_("NOTE: your card will not be charged until the campaign has been queued "
  74 + "for promotion.")}
76 75 </p>
77 76 <input type="hidden" name="customer_id" value="${thing.customer_id}" />
78 77
108 r2/r2/templates/promotelinkform.html
@@ -25,11 +25,12 @@
25 25 from r2.lib.media import thumbnail_url
26 26 from r2.lib.template_helpers import static
27 27 from r2.lib import promote
28   - from r2.lib.pages import SubredditSelector
29 28 from r2.lib.strings import strings
30 29 from r2.models import Account
31 30 from r2.lib import js
32 31 import simplejson
  32 +
  33 + from babel.numbers import format_currency, format_decimal
33 34 %>
34 35
35 36 <%namespace file="utils.html"
@@ -39,13 +40,12 @@
39 40 ${unsafe(js.use('sponsored'))}
40 41
41 42 <%def name="javascript_setup()">
42   - <script type="text/javascript">
43   - $(function() { update_bid("*[name=bid]"); });
44   - </script>
  43 +<script type="text/javascript">
  44 + r.sponsored.init();
  45 + r.sponsored.setup(${unsafe(simplejson.dumps(thing.inventory))})
  46 +</script>
45 47 </%def>
46 48
47   -${self.javascript_setup()}
48   -
49 49 ## Create a datepicker for a form. min/maxDateSrc are the id of the
50 50 ## element containing the min/max date - the '#' is added automatically
51 51 ## here (as a workaround for babel message extraction not handling it
@@ -109,6 +109,8 @@
109 109 ${self.right_panel()}
110 110 </div>
111 111
  112 +${self.javascript_setup()}
  113 +
112 114 <%def name="title_field(link, editable=False)">
113 115 <%utils:line_field title="${_('title')}" id="title-field" css_class="rounded">
114 116 <textarea name="title" rows="2" cols="1"
@@ -266,7 +268,7 @@
266 268
267 269 <table class="preftable">
268 270 <tr>
269   - <th>duration</th>
  271 + <th>dates</th>
270 272 <td class="prefright">
271 273 <%
272 274 mindate = thing.startdate
@@ -278,14 +280,14 @@
278 280 minDateSrc="date-min" initfuncname="init_startdate">
279 281 function(elem) {
280 282 check_enddate(elem, $("#enddate"));
281   - update_bid(elem);
  283 + r.sponsored.on_date_change();
282 284 }
283 285 </%self:datepicker>
284 286 -
285 287 <%self:datepicker name="enddate", value="${thing.enddate}"
286 288 minDateSrc="startdate" initfuncname="init_enddate"
287 289 min_date_offset="86400000">
288   - function(elem) { update_bid(elem); }
  290 + function(elem) { r.sponsored.on_date_change(); }
289 291 </%self:datepicker>
290 292
291 293 ${error_field("BAD_DATE", "startdate", "div")}
@@ -297,44 +299,72 @@
297 299 </tr>
298 300
299 301 <tr>
300   - <th>total bid</th>
  302 + <th>duration</th>
  303 + <td class="prefright duration">
  304 + </td>
  305 + </tr>
  306 +
  307 + <tr>
  308 + <th>total budget</th>
301 309 <td class="prefright">
302 310 ${error_field("BAD_BID", "bid", "div")}
303 311 ${error_field("BID_LIVE", "bid", "div")}
  312 + ${error_field("OVERSOLD_DETAIL", "bid", "div")}
304 313 $<input id="bid" name="bid" size="7" type="text"
305 314 class="rounded styled-input"
306 315 style="width:auto"
307   - onchange="update_bid(this)"
308   - onkeyup="update_bid(this)"
309   - title="Minimum is $${'%.2f' % thing.min_daily_bid} per day, or $${'%.2f' % (thing.min_daily_bid * 1.5)} per day targeted"
310   - value="${'%.2f' % (thing.min_daily_bid * 5)}"
311   - data-min_daily_bid="${thing.min_daily_bid}"/>
312   - <span class="bid-info gray"></span>
  316 + onchange="r.sponsored.on_bid_change()"
  317 + onkeyup="r.sponsored.on_bid_change()"
  318 + title="Minimum is ${format_currency(thing.min_bid, 'USD', locale=c.locale)}"
  319 + value="${format_decimal(5 * thing.min_bid, format='.00', locale=c.locale)}"
  320 + data-min_bid="${thing.min_bid}"
  321 + data-base_cpm="${g.cpm_selfserve.pennies}"/>
  322 + <div class="minimum-spend">
  323 + ${_("$%.2F minimum") % thing.min_bid}
  324 + </div>
313 325 </td>
314 326 </tr>
315 327
316 328 <tr>
  329 + <th>price</th>
  330 + <td class="prefright">
  331 + <input id="cpm" name="cpm" value="${g.cpm_selfserve.pennies}" type="hidden">
  332 + <span class="price-info"></span>
  333 + </td>
  334 + </tr>
  335 +
  336 + <tr>
  337 + <th>impressions</th>
  338 + <td class="prefright">
  339 + <span class="impression-info"></span>
  340 + </td>
  341 + </tr>
  342 +
  343 + <tr>
317 344 <th>targeting</th>
318 345 <td class="prefright">
319   - <input id="no_targeting" class="nomargin"
320   - type="radio" value="none" name="targeting"
321   - onclick="return targeting_off(this)"
322   - checked="checked" />
323   - <label for="no_targeting">no targeting (displays site-wide)</label>
324   - <p id="no_targeting_minimum" class="minimum-spend">minimum $20 / day</p>
325   - <input id="targeting" class="nomargin"
326   - type="radio" value="one" name="targeting"
327   - onclick="return targeting_on(this)" />
328   - <label for="targeting">enable targeting (runs on a specific subreddit)</label>
329   - <p id="targeted_minimum" class="minimum-spend">minimum $30 / day</p>
  346 + <label>
  347 + <input id="no_targeting" class="nomargin"
  348 + type="radio" value="none" name="targeting"
  349 + onclick="r.sponsored.targeting_off()"
  350 + checked="checked" />
  351 + no targeting (displays site-wide)
  352 + </label>
  353 + <br />
  354 + <label>
  355 + <input id="targeting" class="nomargin"
  356 + type="radio" value="one" name="targeting"
  357 + onclick="r.sponsored.targeting_on()" />
  358 + enable targeting (runs on a specific subreddit)
  359 + </label>
330 360
331 361 <script type="text/javascript">
332 362 $(function() {
333 363 var c = $(".campaign input[name=targeting]:checked");
334 364 if (c.val() == 'one') {
335   - targeting_on(c);
  365 + r.sponsored.targeting_on();
336 366 } else {
337   - targeting_off(c);
  367 + r.sponsored.targeting_off();
338 368 }
339 369 })
340 370 </script>
@@ -349,18 +379,20 @@
349 379 init_enddate();
350 380 $("#campaign").find("button[name=create]").show().end()
351 381 .find("button[name=save]").hide().end();
352   - update_bid("*[name=bid]");
353 382 })
354 383 </script>
355 384
356 385 <div class="targeting" style="display:none">
  386 + ${error_field("OVERSOLD", "sr", "div")}
  387 + ${thing.subreddit_selector}
  388 + </div>
  389 +
  390 + <div class="notes">
357 391 <ul>
  392 + <li>You will only be charged for the portion of your budget that is actually spent. Any unspent portion will be refunded.</li>
358 393 <li>By targeting, your ad will only appear in front of users who subscribe to the subreddit that you specify.</li>
359   - <li>Your ad will also appear at the top of the hot listing for that subreddit</li>
360 394 <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>
361 395 </ul>
362   - ${error_field("OVERSOLD", "sr", "div")}
363   - ${SubredditSelector()}
364 396 </div>
365 397
366 398 <div class="buttons">
@@ -403,13 +435,13 @@
403 435 <th title="${start_title}">start</th>
404 436 <th title="${end_title}">end</th>
405 437 <th>duration</th>
406   - <th>bid</th>
  438 + <th>total budget</th>
407 439 <th title="${targeting_title}">targeting</th>
408 440 <th style="align:right">
409 441 <button class="new-campaign fancybutton"
410 442 ${'disabled="disabled"' if len(thing.campaigns) >= g.MAX_CAMPAIGNS_PER_LINK else ''}
411 443 title="${newcamp_title}"
412   - onclick="return create_campaign(this)">+ add new</button>
  444 + onclick="return create_campaign()">+ add new</button>
413 445 </th>
414 446 </tr>
415 447 </table>
@@ -419,7 +451,7 @@
419 451 %for rc in sorted(thing.campaigns, key=lambda rc: rc.start_date):
420 452 $.new_campaign(${unsafe(','.join(simplejson.dumps(attr) for attr in
421 453 [rc.campaign_id36, rc.start_date, rc.end_date, rc.duration,
422   - rc.bid, rc.sr, rc.status]))});
  454 + rc.bid, rc.cpm, rc.sr, rc.status]))});