From a2f0a9e35cb54f34df35788a23edb675bb97a532 Mon Sep 17 00:00:00 2001 From: bsimpson63 Date: Tue, 16 Apr 2013 13:42:49 -0400 Subject: [PATCH] PromoteReport for generating traffic reports on promoted links. --- r2/r2/config/routing.py | 1 + r2/r2/controllers/promotecontroller.py | 29 +++++ r2/r2/lib/pages/pages.py | 155 ++++++++++++++++++++++++- r2/r2/public/static/css/reddit.less | 40 +++++++ r2/r2/templates/promotereport.html | 153 ++++++++++++++++++++++++ 5 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 r2/r2/templates/promotereport.html diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 51741c2fd..38f306189 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -181,6 +181,7 @@ def make_map(): mc('/promoted/:action', controller='promote', requirements=dict(action="edit_promo|new_promo|roadblock")) + mc('/promoted/report', controller='promote', action='report') mc('/promoted/:sort/:sr', controller='promote', action='listing', requirements=dict(sort='live_promos')) mc('/promoted/:sort', controller='promote', action="listing") diff --git a/r2/r2/controllers/promotecontroller.py b/r2/r2/controllers/promotecontroller.py index 1c8e43e38..ee2296ecd 100644 --- a/r2/r2/controllers/promotecontroller.py +++ b/r2/r2/controllers/promotecontroller.py @@ -44,6 +44,8 @@ from r2.lib.pages import ( PromotePage, PromoteLinkForm, PromoteLinkFormCpm, + PromoteReport, + Reddit, Roadblocks, UploadedImage, ) @@ -758,4 +760,31 @@ class PromoteController(ListingController): start=dates[0], end=dates[1]).render() + @validate(VSponsorAdmin(), + start=VDate('startdate'), + end=VDate('enddate'), + link_text=nop('link_text')) + def GET_report(self, start, end, link_text=None): + now = datetime.now(g.tz).replace(hour=0, minute=0, second=0, + microsecond=0) + end = end or now - timedelta(days=1) + start = start or end - timedelta(days=7) + if link_text is not None: + names = link_text.replace(',', ' ').split() + try: + links = Link._by_fullname(names, data=True) + except NotFound: + links = {} + + bad_links = [name for name in names if name not in links] + links = links.values() + else: + links = [] + bad_links = [] + + content = PromoteReport(links, link_text, bad_links, start, end) + if c.render_style == 'csv': + return content.as_csv() + else: + return PromotePage('report', content=content).render() diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index ebcad2cdc..85085be9b 100755 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -70,10 +70,15 @@ from r2.lib.memoize import memoize from r2.lib.utils import trunc_string as _truncate, to_date from r2.lib.filters import safemarkdown +from babel.numbers import format_currency +from collections import defaultdict +import csv +import cStringIO +import pytz import sys, random, datetime, calendar, simplejson, re, time import time -from itertools import chain -from urllib import quote +from itertools import chain, product +from urllib import quote, urlencode # the ip tracking code is currently deeply tied with spam prevention stuff # this will be open sourced as soon as it can be decoupled @@ -3170,6 +3175,7 @@ class PromotePage(Reddit): if c.user_is_sponsor: buttons.append(NamedButton('admin_graph', dest='/admin/graph')) + buttons.append(NavButton('report', 'report')) menu = NavMenu(buttons, base_path = '/promoted', type='flatlist') @@ -3735,6 +3741,151 @@ class Promote_Graph(Templated): "$%.2f" % link.promote_bid, _force_unicode(link.title)) + +class PromoteReport(Templated): + def __init__(self, links, link_text, bad_links, start, end): + self.links = links + self.start = start + self.end = end + if links: + self.make_link_report() + self.make_campaign_report() + p = request.get.copy() + self.csv_url = '%s.csv?%s' % (request.path, urlencode(p)) + else: + self.link_report = None + self.campaign_report = None + self.csv_url = None + + Templated.__init__(self, link_text=link_text, bad_links=bad_links) + + def as_csv(self): + out = cStringIO.StringIO() + writer = csv.writer(out) + + writer.writerow((_("start date"), self.start.strftime('%m/%d/%Y'))) + writer.writerow((_("end date"), self.end.strftime('%m/%d/%Y'))) + writer.writerow([]) + writer.writerow((_("links"),)) + writer.writerow(( + _("name"), + _("owner"), + _("comments"), + _("upvotes"), + _("downvotes"), + )) + for row in self.link_report: + writer.writerow((row['name'], row['owner'], row['comments'], + row['upvotes'], row['downvotes'])) + + writer.writerow([]) + writer.writerow((_("campaigns"),)) + writer.writerow(( + _("link"), + _("owner"), + _("campaign"), + _("target"), + _("bid"), + _("frontpage clicks"), _("frontpage impressions"), + _("subreddit clicks"), _("subreddit impressions"), + _("total clicks"), _("total impressions"), + )) + for row in self.campaign_report: + writer.writerow( + (row['link'], row['owner'], row['campaign'], row['target'], + row['bid'], row['fp_clicks'], row['fp_impressions'], + row['sr_clicks'], row['sr_impressions'], row['total_clicks'], + row['total_impressions']) + ) + return out.getvalue() + + def make_link_report(self): + link_report = [] + owners = Account._byID([link.author_id for link in self.links], + data=True) + + for link in self.links: + row = { + 'name': link._fullname, + 'owner': owners[link.author_id].name, + 'comments': link.num_comments, + 'upvotes': link._ups, + 'downvotes': link._downs, + } + link_report.append(row) + self.link_report = link_report + + @classmethod + def _get_hits(cls, traffic_cls, campaigns, start, end): + campaigns_by_name = {camp._fullname: camp for camp in campaigns} + codenames = campaigns_by_name.keys() + start = (start - promote.timezone_offset).replace(tzinfo=None) + end = (end - promote.timezone_offset).replace(tzinfo=None) + hits = traffic_cls.campaign_history(codenames, start, end) + sr_hits = defaultdict(int) + fp_hits = defaultdict(int) + for date, codename, sr, (uniques, pageviews) in hits: + campaign = campaigns_by_name[codename] + campaign_start = campaign.start_date - promote.timezone_offset + campaign_end = campaign.end_date - promote.timezone_offset + date = date.replace(tzinfo=g.tz) + if date < campaign_start or date > campaign_end: + continue + if sr == '': + fp_hits[codename] += pageviews + else: + sr_hits[codename] += pageviews + return fp_hits, sr_hits + + @classmethod + def get_imps(cls, campaigns, start, end): + return cls._get_hits(traffic.TargetedImpressionsByCodename, campaigns, + start, end) + + @classmethod + def get_clicks(cls, campaigns, start, end): + return cls._get_hits(traffic.TargetedClickthroughsByCodename, campaigns, + start, end) + + def make_campaign_report(self): + campaigns = PromoCampaign._by_link([link._id for link in self.links]) + + def keep_camp(camp): + return not (camp.start_date.date() >= self.end.date() or + camp.end_date.date() <= self.start.date() or + not camp.trans_id) + + campaigns = [camp for camp in campaigns if keep_camp(camp)] + fp_imps, sr_imps = self.get_imps(campaigns, self.start, self.end) + fp_clicks, sr_clicks = self.get_clicks(campaigns, self.start, self.end) + owners = Account._byID([link.author_id for link in self.links], + data=True) + links_by_id = {link._id: link for link in self.links} + campaign_report = [] + + for camp in campaigns: + link = links_by_id[camp.link_id] + fullname = camp._fullname + camp_duration = (camp.end_date - camp.start_date).days + effective_duration = (min(camp.end_date, self.end) + - max(camp.start_date, self.start)).days + bid = camp.bid * (float(effective_duration) / camp_duration) + row = { + 'link': link._fullname, + 'owner': owners[link.author_id].name, + 'campaign': fullname, + 'target': camp.sr_name or 'frontpage', + 'bid': format_currency(bid, 'USD'), + 'fp_impressions': fp_imps[fullname], + 'sr_impressions': sr_imps[fullname], + 'fp_clicks': fp_clicks[fullname], + 'sr_clicks': sr_clicks[fullname], + 'total_impressions': fp_imps[fullname] + sr_imps[fullname], + 'total_clicks': fp_clicks[fullname] + sr_clicks[fullname], + } + campaign_report.append(row) + self.campaign_report = sorted(campaign_report, key=lambda r: r['link']) + class InnerToolbarFrame(Templated): def __init__(self, link, expanded = False): Templated.__init__(self, link = link, expanded = expanded) diff --git a/r2/r2/public/static/css/reddit.less b/r2/r2/public/static/css/reddit.less index 921f1d088..1a3be95da 100755 --- a/r2/r2/public/static/css/reddit.less +++ b/r2/r2/public/static/css/reddit.less @@ -4853,6 +4853,46 @@ table.calendar { border: none; } +.promote-report-form { + margin: 1.5em 2em; +} + +.promote-report-csv { + font-size: small; +} + +.promote-report-table { + border: 0 none; + font-size: small; + margin: 1.5em 2em; + + thead th { + font-weight: bold; + text-align: center; + padding: 0 1em; + border: 1px solid white; + background-color: #CEE3F8; + } + + thead th.blank { + background: none; + } + + thead th[colspan] { + text-align: center; + } + + td { + text-align: right; + padding: 0 2em; + } + + td.text { + text-align: left; + padding: 0 2em 0 0; + } +} + /* title box */ .titlebox { font-size: larger; diff --git a/r2/r2/templates/promotereport.html b/r2/r2/templates/promotereport.html new file mode 100644 index 000000000..ae5ec8446 --- /dev/null +++ b/r2/r2/templates/promotereport.html @@ -0,0 +1,153 @@ +## 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. +############################################################################### + + +<%! + from r2.lib import js + from r2.lib.template_helpers import format_number +%> + +<%namespace name="pr" file="promotelinkform.html" /> +<%namespace file="utils.html" import="plain_link" /> + +${unsafe(js.use('sponsored'))} + +

sponsored link report

+ +
+
+

date range

+
+ note: the end date is not included, so selecting 1/2/2013-1/3/2013 will retrieve traffic for the day of 1/2/2013 only. +
+ <%pr:datepicker name="startdate" value="${thing.start.strftime('%m/%d/%Y')}" + minDateSrc="date-min" initfuncname="init_startdate" + min_date_offset="86400000"> + function(elem) { check_enddate(elem, $("#enddate")); return elem; } + +  –  + <%pr:datepicker name="enddate" value="${thing.end.strftime('%m/%d/%Y')}" + minDateSrc="startdate" initfuncname="init_enddate" + min_date_offset="86400000"> + function(elem) { return elem; } + + +

link names

+ + %if thing.bad_links: +
+ ${"%s not found" % (', '.join(thing.bad_links))} +
+ %endif +
+ +
+
+ +%if thing.link_report: +

links

+ + + + + + + + + + + + %for row in thing.link_report: + + + + + + + + %endfor + + +%endif + +%if thing.campaign_report: +

campaigns

+ + + + + + + + + + + + + + + + + + + + + + + %for row in thing.campaign_report: + + + + + + + + + + + + + + %endfor + +
+ + + + + frontpagesubreddittotal
linkownercampaigntargetbidclicksimpressionsclicksimpressionsclicksimpressions
${row['link']}${row['owner']}${row['campaign']}${row['target']}${row['bid']}${format_number(row['fp_clicks'])}${format_number(row['fp_impressions'])}${format_number(row['sr_clicks'])}${format_number(row['sr_impressions'])}${format_number(row['total_clicks'])}${format_number(row['total_impressions'])}
+%endif + +%if thing.csv_url: +
+ ${plain_link(unsafe("download as csv"), thing.csv_url)} +
+%endif + +