diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 2cd60901d..581b5471d 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -203,6 +203,8 @@ def make_map(): action='edit_promo_campaign') mc('/promoted/pay/:link/:campaign', controller='promote', action='pay') + mc('/promoted/refund/:link/:campaign', controller='promote', + action='refund') mc('/promoted/graph', controller='promote', action='graph') mc('/promoted/admin/graph', controller='promote', action='admingraph') @@ -330,7 +332,8 @@ def make_map(): "freebie|promote_note|update_pay|refund|" "traffic_viewer|rm_traffic_viewer|" "edit_campaign|delete_campaign|meta_promo|" - "add_roadblock|rm_roadblock|check_inventory"))) + "add_roadblock|rm_roadblock|check_inventory|" + "refund_campaign"))) 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 6133a4968..d7fb914dd 100644 --- a/r2/r2/controllers/promotecontroller.py +++ b/r2/r2/controllers/promotecontroller.py @@ -47,13 +47,14 @@ from r2.lib.pages import ( PromoteLinkNew, PromoteReport, Reddit, + RefundPage, Roadblocks, UploadedImage, ) 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, to_date +from r2.lib.utils import make_offset_date, to_date, to36 from r2.lib.validator import ( json_validate, nop, @@ -196,6 +197,12 @@ class PromoteController(ListingController): return self.live_by_subreddit(self.sr) elif self.sort == 'live_promos': return queries.get_all_live_links() + elif self.sort == 'underdelivered': + q = queries.get_underdelivered_campaigns() + campaigns = PromoCampaign._by_fullname(list(q), data=True, + return_dict=False) + link_ids = [camp.link_id for camp in campaigns] + return [Link._fullname_from_id36(to36(id)) for id in link_ids] return queries.get_all_promoted_links() else: if self.sort == "future_promos": @@ -315,6 +322,30 @@ class PromoteController(ListingController): if promote.is_promo(thing): promote.reject_promotion(thing, reason=reason) + @validate(VSponsorAdmin(), + link=VLink("link"), + campaign=VPromoCampaign("campaign")) + def GET_refund(self, link, campaign): + if campaign.link_id != link._id: + return self.abort404() + + content = RefundPage(link, campaign) + return Reddit("refund", content=content, show_sidebar=False).render() + + @validatedForm(VSponsorAdmin(), + link=VLink('link'), + campaign=VPromoCampaign('campaign')) + def POST_refund_campaign(self, form, jquery, link, campaign): + billable_impressions = promote.get_billable_impressions(campaign) + billable_amount = promote.get_billable_amount(campaign, + billable_impressions) + refund_amount = campaign.bid - billable_amount + if refund_amount > 0: + promote.refund_campaign(link, campaign, billable_amount) + form.set_html('.status', _('refund succeeded')) + else: + form.set_html('.status', _('refund not needed')) + @validatedForm(VSponsor('link_id'), VModhash(), VRatelimit(rate_user=True, diff --git a/r2/r2/lib/db/queries.py b/r2/r2/lib/db/queries.py index 3e639c1db..0a76bb2fc 100755 --- a/r2/r2/lib/db/queries.py +++ b/r2/r2/lib/db/queries.py @@ -745,6 +745,25 @@ def get_all_accepted_links(): return _promoted_link_query(None, 'accepted') +@cached_query(UserQueryCache, sort=[desc('_date')]) +def get_underdelivered_campaigns(): + return + + +def set_underdelivered_campaigns(campaigns): + campaigns = tup(campaigns) + with CachedQueryMutator() as m: + q = get_underdelivered_campaigns() + m.insert(q, campaigns) + + +def unset_underdelivered_campaigns(campaigns): + campaigns = tup(campaigns) + with CachedQueryMutator() as m: + q = get_underdelivered_campaigns() + m.delete(q, campaigns) + + @merged_cached_query def get_promoted_links(user_id): queries = [get_unpaid_links(user_id), get_unapproved_links(user_id), diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index f2da2fa9f..4b5c5fef1 100755 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -3296,6 +3296,7 @@ class PromotePage(Reddit): buttons.append(NamedButton('admin_graph', dest='/admin/graph')) buttons.append(NavButton('report', 'report')) + buttons.append(NavButton('underdelivered', 'underdelivered')) menu = NavMenu(buttons, base_path = '/promoted', type='flatlist') @@ -3445,6 +3446,22 @@ class PromoAdminTool(Reddit): return promo_info +class RefundPage(Reddit): + def __init__(self, link, campaign): + self.link = link + self.campaign = campaign + self.listing = wrap_links(link, wrapper=promote.sponsor_wrapper, + skip=False) + billable_impressions = promote.get_billable_impressions(campaign) + billable_amount = promote.get_billable_amount(campaign, + billable_impressions) + refund_amount = campaign.bid - billable_amount + self.billable_impressions = billable_impressions + self.billable_amount = billable_amount + self.refund_amount = refund_amount + self.traffic_url = '/traffic/%s/%s' % (link._id36, campaign._id36) + Reddit.__init__(self, title="refund", show_sidebar=False) + class Roadblocks(Templated): def __init__(self): diff --git a/r2/r2/lib/promote.py b/r2/r2/lib/promote.py index b263304df..a8de962c9 100644 --- a/r2/r2/lib/promote.py +++ b/r2/r2/lib/promote.py @@ -41,7 +41,11 @@ from r2.lib import ( inventory, hooks, ) -from r2.lib.db.queries import set_promote_status +from r2.lib.db.queries import ( + set_promote_status, + set_underdelivered_campaigns, + unset_underdelivered_campaigns, +) from r2.lib.memoize import memoize from r2.lib.organic import keep_fresh_links from r2.lib.strings import strings @@ -125,6 +129,12 @@ def view_live_url(l, srname): url += '/r/%s' % srname return 'http://%s/?ad=%s' % (url, l._fullname) + +def refund_url(link, campaign): + return "%spromoted/refund/%s/%s" % (g.payment_domain, link._id36, + campaign._id36) + + # booleans def is_promo(link): @@ -220,6 +230,11 @@ class RenderableCampaign(): status['paid'] = False status['free'] = False + if complete and user_is_sponsor and not transaction.is_refund(): + if spent < bid: + status['refund'] = True + status['refund_url'] = refund_url(link, camp) + rc = cls(campaign_id36, start_date, end_date, duration, bid, spent, cpm, sr, status) r.append(rc) @@ -776,6 +791,7 @@ def finalize_completed_campaigns(daysago=1): "Missing traffic from %s" % (date, missing_traffic)) links = Link._byID([camp.link_id for camp in campaigns], data=True) + underdelivered_campaigns = [] for camp in campaigns: if hasattr(camp, 'refund_amount'): @@ -793,26 +809,35 @@ def finalize_completed_campaigns(daysago=1): text = '%s completed with $%s billable (pre-CPM).' text %= (camp, billable_amount) PromotionLog.add(link, text) - refund_amount = 0. + camp.refund_amount = 0. + camp._commit() 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) + underdelivered_campaigns.append(camp) - camp.refund_amount = refund_amount - camp._commit() + if underdelivered_campaigns: + set_underdelivered_campaigns(underdelivered_campaigns) + + +def refund_campaign(link, camp, billable_amount): + refund_amount = camp.bid - billable_amount + owner = Account._byID(camp.owner_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) + return + + 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() + unset_underdelivered_campaigns(camp) PromoTuple = namedtuple('PromoTuple', ['link', 'weight', 'campaign']) diff --git a/r2/r2/public/static/css/reddit.less b/r2/r2/public/static/css/reddit.less index 641ef1e11..9af63a6d7 100755 --- a/r2/r2/public/static/css/reddit.less +++ b/r2/r2/public/static/css/reddit.less @@ -4259,6 +4259,11 @@ ul.tabmenu.formtab { background-image: url(../green-check.png); } +.existing-campaigns tr.refund { + color: red; + font-weight: bold; +} + .existing-campaigns td.bid .info{ margin-right: 3px; } diff --git a/r2/r2/public/static/js/sponsored.js b/r2/r2/public/static/js/sponsored.js index 8cd350336..372d2340b 100644 --- a/r2/r2/public/static/js/sponsored.js +++ b/r2/r2/public/static/js/sponsored.js @@ -272,6 +272,9 @@ function get_flag_class(flags) { if (flags.sponsor) { css_class += " sponsor"; } + if (flags.refund) { + css_class += " refund"; + } return css_class } @@ -295,6 +298,10 @@ $.new_campaign = function(campaign_id36, start_date, end_date, duration, data += (""); } + if (flags && flags.refund_url) { + data += (""); + } var row = [start_date, end_date, duration, "$" + bid, "$" + spent, targeting, data]; $(".existing-campaigns .error").hide(); var css_class = get_flag_class(flags); @@ -338,6 +345,7 @@ $.set_up_campaigns = function() { var free = ""; var repay = ""; var view = ""; + var refund = ""; $(".existing-campaigns tr").each(function() { var tr = $(this); var td = $(this).find("td:last"); @@ -348,6 +356,12 @@ $.set_up_campaigns = function() { $(td).append($(view).addClass("view fancybutton") .click(function() { view_campaign(tr) })); } + + if (tr.hasClass('refund')) { + $(bid_td).append($(refund).addClass("refund fancybutton") + .click(function() { refund_campaign(tr) })); + } + /* once paid, we shouldn't muck around with the campaign */ if(!tr.hasClass("complete") && !tr.hasClass("live")) { if (tr.hasClass("sponsor") && !tr.hasClass("free")) { @@ -544,3 +558,7 @@ function pay_campaign(elem) { function view_campaign(elem) { $.redirect($(elem).find('input[name="view_live_url"]').val()); } + +function refund_campaign(elem) { + $.redirect($(elem).find('input[name="refund_url"]').val()); +} diff --git a/r2/r2/templates/refundpage.html b/r2/r2/templates/refundpage.html new file mode 100644 index 000000000..e4684870c --- /dev/null +++ b/r2/r2/templates/refundpage.html @@ -0,0 +1,96 @@ +## 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-2013 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%! + import simplejson + from babel.numbers import format_currency, format_number + from r2.lib.utils import to36 +%> + +<%namespace file="utils.html" import="plain_link"/> + +
+

${_("refund promotion")}

+ + ${thing.listing} + +

${_("campaign")}

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
${_("id")}${thing.campaign._id36}
${_("dates")} + ${thing.campaign.start_date.strftime("%m/%d/%Y")} + - + ${thing.campaign.end_date.strftime("%m/%d/%Y")} +
${_("target")}${thing.campaign.sr_name or "Frontpage"}
${_("budget")}${format_currency(thing.campaign.bid, 'USD', locale=c.locale)}
${_("cpm")}${format_currency(thing.campaign.cpm / 100., 'USD', locale=c.locale)}
${_("impressions purchased")}${format_number(thing.campaign.impressions, locale=c.locale)}
${_("impressions received")} + ${format_number(thing.billable_impressions, locale=c.locale)} + + (${plain_link(_("detail"), thing.traffic_url)}) +
${_("billable amount")}${format_currency(thing.billable_amount, 'USD', locale=c.locale)}
${_("refund amount")}${format_currency(thing.refund_amount, 'USD', locale=c.locale)}
+ + + + + + +
+ +
\ No newline at end of file