Available impressions viz on campaign edit page

Adds callback to the subreddit selector on the promo edit page that updates
an available inventory graph when the selected target changes. Shows available
front page inventory if no target is selected.

Right now this is set up in a new route. Eventually it should replace the old
promoted/edit_promo route.

TODO: change color of graph to show when inventory is running low
TODO: i18n of javascript strings
This commit is contained in:
shlurbee
2012-10-29 10:36:07 -07:00
parent 9519af54ab
commit 6a40f4e5bc
9 changed files with 207 additions and 4 deletions

View File

@@ -142,14 +142,18 @@ def make_map():
mc('/admin/promoted', controller='promote', action='admin')
mc('/promoted/edit_promo/:link',
controller='promote', action='edit_promo')
mc('/promoted/edit_promo_cpm/:link', # development only (don't link to url)
controller='promote', action='edit_promo_cpm')
mc('/promoted/edit_promo/pc/:campaign', controller='promote', # admin only
action='edit_promo_campaign')
mc('/promoted/pay/:link/:campaign',
controller='promote', action='pay')
mc('/promoted/graph',
controller='promote', action='graph')
mc('/promoted/traffic/headline/:link', controller='front', action='promo_traffic')
mc('/promoted/inventory/:sr_name',
controller='promote', action='inventory')
mc('/promoted/traffic/headline/:link',
controller='front', action='promo_traffic')
mc('/promoted/:action', controller='promote',
requirements=dict(action="edit_promo|new_promo|roadblock"))

View File

@@ -20,6 +20,8 @@
# Inc. All Rights Reserved.
###############################################################################
import json
from validator import *
from pylons.i18n import _
from r2.models import *
@@ -112,6 +114,25 @@ class PromoteController(ListingController):
return page.render()
# For development. Should eventually replace GET_edit_promo
@validate(VSponsor('link'),
link = VLink('link'))
def GET_edit_promo_cpm(self, link):
if not link or link.promoted is None:
return self.abort404()
rendered = wrap_links(link, wrapper = promote.sponsor_wrapper,
skip = False)
form = PromoteLinkFormCpm(link = link,
listing = rendered,
timedeltatext = "")
page = PromotePage('new_promo', content = form)
return page.render()
# admin only because the route might change
@validate(VSponsorAdmin('campaign'),
campaign=VPromoCampaign('campaign'))
@@ -129,6 +150,28 @@ class PromoteController(ListingController):
return c.response
return PromotePage("graph", content = content).render()
def GET_inventory(self, sr_name):
'''
Return available inventory data as json for use in ajax calls
'''
inv_start_date = promote.promo_datetime_now()
inv_end_date = inv_start_date + timedelta(60)
inventory = promote.get_available_impressions(sr_name,
inv_start_date,
inv_end_date,
fuzzed=(not c.user_is_admin))
dates = []
impressions = []
max_imps = 0
for date, imps in inventory.iteritems():
dates.append(date.strftime("%m/%d/%Y"))
impressions.append(imps)
max_imps = max(max_imps, imps)
return json.dumps({'sr':sr_name,
'dates': dates,
'imps':impressions,
'max_imps':max_imps})
### POST controllers below
@validatedForm(VSponsorAdmin(),

View File

@@ -3164,6 +3164,12 @@ class PromotePage(Reddit):
class PromoteLinkForm(Templated):
def __init__(self, sr=None, link=None, listing='',
timedeltatext='', *a, **kw):
self.setup(sr, link, listing, timedeltatext, *a, **kw)
Templated.__init__(self, sr=sr, datefmt = datefmt,
timedeltatext=timedeltatext, listing = listing,
bids = self.bids, *a, **kw)
def setup(self, sr, link, listing, timedeltatext, *a, **kw):
bids = []
if c.user_is_sponsor and link:
self.author = Account._byID(link.author_id)
@@ -3211,11 +3217,24 @@ class PromoteLinkForm(Templated):
self.market, self.promo_counter = \
Promote_Graph.get_market(None, start_date, end_date)
self.bids = bids
self.min_daily_bid = 0 if c.user_is_admin else g.min_promote_bid
class PromoteLinkFormCpm(PromoteLinkForm):
def __init__(self, sr=None, link=None, listing='',
timedeltatext='', *a, **kw):
self.setup(sr, link, listing, timedeltatext, *a, **kw)
if not c.user_is_sponsor:
self.now = promote.promo_datetime_now().date()
start_date = self.now
end_date = self.now + datetime.timedelta(60) # two months
self.inventory = promote.get_available_impressions(sr, start_date, end_date)
Templated.__init__(self, sr=sr, datefmt = datefmt,
timedeltatext=timedeltatext, listing = listing,
bids = bids, *a, **kw)
bids = self.bids, *a, **kw)
class PromoAdminTool(Reddit):

View File

@@ -22,6 +22,7 @@
from __future__ import with_statement
from collections import OrderedDict
import json
import time
@@ -31,8 +32,9 @@ from r2.models.keyvalue import NamedGlobals
from r2.lib.wrapped import Wrapped
from r2.lib import authorize
from r2.lib import emailer
from r2.lib import inventory
from r2.lib.template_helpers import get_domain
from r2.lib.utils import Enum, UniqueIterator, tup
from r2.lib.utils import Enum, UniqueIterator, tup, to_date
from r2.lib.organic import keep_fresh_links
from pylons import g, c
from datetime import datetime, timedelta
@@ -538,6 +540,37 @@ def get_scheduled(offset=0):
error_campaigns.append((campaign._id, e))
return {'by_sr': by_sr, 'links': links, 'error_campaigns': error_campaigns}
def fuzz_impressions(imps):
"""Return imps rounded to one significant digit."""
if imps > 0:
ndigits = int(math.floor(math.log10(imps)))
return int(round(imps, -1*ndigits)) # note the negative
else:
return 0
def get_scheduled_impressions(sr_name, start_date, end_date):
# FIXME: mock function for development
start_date = to_date(start_date)
end_date = to_date(end_date)
ndays = (end_date - start_date).days
scheduled = OrderedDict()
for i in range(ndays):
date = (start_date + timedelta(i))
scheduled[date] = random.randint(0, 100) # FIXME: fakedata
return scheduled
def get_available_impressions(sr_name, start_date, end_date, fuzzed=False):
# FIXME: mock function for development
start_date = to_date(start_date)
end_date = to_date(end_date)
available = inventory.get_predicted_by_date(sr_name, start_date, end_date)
scheduled = get_scheduled_impressions(sr_name, start_date, end_date)
for date in scheduled:
available[date] = int(available[date] - (available[date]*scheduled[date]/100.)) # DELETEME
#available[date] = max(0, available[date] - scheduled[date]) # UNCOMMENTME
if fuzzed:
available[date] = fuzz_impressions(available[date])
return available
def charge_pending(offset=1):
for l, camp, weight in accepted_campaigns(offset=offset):

View File

@@ -810,6 +810,7 @@ function sr_name_down(e) {
return false;
}
else if (e.keyCode == 13) {
$("#sr-autocomplete").trigger("sr-changed");
hide_sr_name_list();
input.parents("form").submit();
return false;
@@ -830,12 +831,14 @@ function sr_dropdown_mup(row) {
var name = $(row).text();
$("#sr-autocomplete").val(name);
$("#sr-drop-down").hide();
$("#sr-autocomplete").trigger("sr-changed");
}
}
function set_sr_name(link) {
var name = $(link).text();
$("#sr-autocomplete").trigger('focus').val(name);
$("#sr-autocomplete").trigger("sr-changed");
}
/*** tabbed pane stuff ***/

View File

@@ -374,3 +374,29 @@ function pay_campaign(elem) {
function view_campaign(elem) {
$.redirect($(elem).find('input[name="view_live_url"]').val());
}
// writes rows into inventory table when subreddit selector changes
function update_inventory_table() {
var sr = $('#targeting').attr('checked') ? $('#sr-autocomplete').val() : ' reddit.com';
$.ajax({
url: '/promoted/inventory/' + sr,
type: 'GET',
dataType: 'json',
// on success, update title to show subreddit name and fill table rows
success: function(data) { // {'sr':'funny', 'dates':[...], 'imps':[...]}
var sr_name = (data['sr'] == ' reddit.com') ? 'front page' : data['sr'];
$('#inventory-title > span').text('available ' + sr_name + ' impressions'); // FIXME: i18n
$('#inventory').empty();
$('#inventory').append('<tr><th>date</th><th>imps</th><th></th></tr>');
$.each(data['imps'], function(i) {
var w = Math.round(50. * data['imps'][i] / data['max_imps']);
var row = ['<tr>',
'<th>' + data['dates'][i] + '</th>',
'<td>' + data['imps'][i] + '</td>',
'<td><div class="graph" style="width:' + w.toString() + 'px" /></td>',
'</tr>'];
$('#inventory > tbody').append(row.join(''))
});
}
});
}

View File

@@ -35,10 +35,14 @@
<%namespace name="utils" file="utils.html"/>
${unsafe(js.use('sponsored'))}
<%def name="javascript_setup()">
<script type="text/javascript">
$(function() { update_bid("*[name=bid]"); });
</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
@@ -413,6 +417,7 @@ ${unsafe(js.use('sponsored'))}
</div>
</div>
<%def name="right_panel()">
%if thing.link and not c.user_is_sponsor:
<div class="bidding-history" style="padding-top: 0px;">
<%utils:line_field title="${_('promotion history')}" css_class="rounded">
@@ -457,6 +462,9 @@ ${unsafe(js.use('sponsored'))}
</%utils:line_field>
</div>
%endif
</%def>
${self.right_panel()}
%if thing.link and c.user_is_sponsor:
<div class="spacer bidding-history">

View File

@@ -0,0 +1,62 @@
## The contents of this file are subject to the Common Public Attribution
## License Version 1.0. (the "License"); you may not use this file except in
## compliance with the License. You may obtain a copy of the License at
## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
## License Version 1.1, but Sections 14 and 15 have been added to cover use of
## software over a computer network and provide for limited attribution for the
## Original Developer. In addition, Exhibit A has been modified to be
## consistent with Exhibit B.
##
## Software distributed under the License is distributed on an "AS IS" basis,
## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
## the specific language governing rights and limitations under the License.
##
## The Original Code is reddit.
##
## The Original Developer is the Initial Developer. The Initial Developer of
## the Original Code is reddit Inc.
##
## All portions of the code written by reddit are Copyright (c) 2006-2012
## reddit Inc. All Rights Reserved.
###############################################################################
<%inherit file="promotelinkform.html" />
<%namespace file="utils.html"
import="error_field, checkbox, image_upload, reddit_selector" />
<%namespace name="utils" file="utils.html"/>
<%def name="javascript_setup()">
<script type="text/javascript">
$(function() {
update_bid("*[name=bid]");
// load available impressions viz
update_inventory_table();
// add change handler to radio that will load front-page imps when
// user switches from targeted to untargeted
$("#no_targeting").change(function() {
$("#sr-autocomplete").val("");
update_inventory_table();
});
// wire sr selector to update inventory viz on selection change
$("#sr-autocomplete").bind("sr-changed", update_inventory_table);
});
</script>
</%def>
<%def name="right_panel()">
## title and table rows are dynamically generated when user selects a target
%if thing.link and not c.user_is_sponsor:
<div class="bidding-history" style="padding-top: 0px;">
<%utils:line_field id="inventory-title" title="" css_class="rounded">
<table id="inventory" class="bidding-history">
</table>
</%utils:line_field>
</div>
%endif
</%def>

View File

@@ -524,6 +524,11 @@ ${unsafe(txt)}
</%def>
<%def name="reddit_selector(default_sr, sr_searches, subreddits)">
<%doc>
Fires custom event when subreddit selection changes. Add handler like:
$("#sr-autocomplete").bind("sr-changed", function() { dostuff; })
</%doc>
<div id="sr-autocomplete-area">
<input id="sr-autocomplete" name="sr" type="text"
autocomplete="off"