Permalink
Browse files

Added graph generation and changed emails to HTML.

  • Loading branch information...
1 parent 48154e4 commit 3e549ae957a3843449bf8036fde0b5308b2e8a81 Basil Shkara committed Sep 9, 2009
View
@@ -10,28 +10,35 @@ indexes:
# automatically uploaded to the admin console when you next deploy
# your application using appcfg.py.
-# Used 5 times in query history.
+# Used 36 times in query history.
- kind: Ranking
properties:
- name: pid
- name: date_created
direction: desc
-# Used 27 times in query history.
+# Used 253 times in query history.
- kind: Sale
properties:
- name: pid
- name: report_date
-# Used 5 times in query history.
+# Used 36 times in query history.
- kind: Sale
properties:
- name: pid
- name: report_date
direction: desc
-# Used 5 times in query history.
+# Used 72 times in query history.
- kind: Upgrade
properties:
- name: pid
- name: report_date
+
+# Unused in query history -- copied from input.
+- kind: Upgrade
+ properties:
+ - name: pid
+ - name: report_date
+ direction: desc
View
@@ -4,13 +4,22 @@
from google.appengine.api import mail
from google.appengine.ext.webapp.util import login_required
from google.appengine.api.labs import taskqueue
+from google.appengine.ext.webapp import template
import datetime
import string
import locale
+import sys
+import os
import settings
import models.data
+# Append lib path to sys.path for Graphy
+sys.path.insert(0, settings.APP_ROOT_DIR + '/lib')
+from graphy.backends import google_chart_api
+from graphy import formatters
+from graphy import line_chart
+
class EmailReport(webapp.RequestHandler):
@@ -89,6 +98,7 @@ def get(self):
dict = {'country': ranking.country, 'category': ranking.category, 'ranking': ranking.ranking}
rankings.append(dict)
+ overall_chart_url, concentrated_chart_url = self.units_chart(pid)
product = {
'name': product_name,
'last_reported_sales_total': last_reported_sales_total,
@@ -104,10 +114,13 @@ def get(self):
'upgrade_base': self._format_number(upgrade_base),
'currency': settings.SETTINGS['base_currency'],
'rankings_pull_date': last_pull_date,
- 'rankings': self._rankings_string(rankings),
+ 'rankings': rankings,
+ 'overall_chart_url': overall_chart_url,
+ 'concentrated_chart_url': concentrated_chart_url,
}
- email_body = self._email_body(product)
+ path = os.path.join(settings.SETTINGS['template_path'], 'report.html')
+ email_body = template.render(path, product)
subject = '%s %s App Store report' % (datetime.date.today().strftime('%Y%m%d'), product['name'])
self.send_email(pid, subject, email_body)
@@ -118,60 +131,127 @@ def _total_income_units(self, reports):
total += report.income_units
return total
- def _email_body(self, product):
- body_template = string.Template("""Hello,
-
-Here is your daily report for $name
---
-
-Yesterday's ($sales_end) download figures:
- - $last_reported_sales_total
-
-Yesterday's ($sales_end) upgrade figures:
- - $last_reported_upgrades_total
-
-Total number of downloads ($sales_start to $sales_end):
- - $sales_total
-
-Total number of upgrades ($upgrades_start to $upgrades_end):
- - $upgrades_total
-
-Upgrade rate (over base of $upgrade_base):
- - $upgrade_rate
-
-Approximate total income revenue ($currency):
- - $sales_total_revenue
-
-Rankings (as of $rankings_pull_date UTC):
-
-$rankings
-""")
- return body_template.substitute(product)
-
def _date_string(self, date):
return date.strftime('%Y-%m-%d')
def _format_number(self, number):
locale.setlocale(locale.LC_ALL,"")
return locale.format('%d', number, True)
- def _rankings_string(self, rankings):
- body = ''
- separator_width = 25
- header = 'Country'.ljust(separator_width, ' ') + 'Category'.ljust(separator_width, ' ') + 'Ranking'.ljust(separator_width, ' ')
- body += '\t' + header + '\n'
- header_underline = '-------'.ljust(separator_width, ' ') + '--------'.ljust(separator_width, ' ') + '-------'.ljust(separator_width, ' ')
- body += '\t' + header_underline + '\n'
- for ranking in rankings:
- row = ranking['country'].ljust(separator_width, ' ') + ranking['category'].ljust(separator_width, ' ') + str(ranking['ranking']).ljust(separator_width, ' ')
- body += '\t' + row + '\n'
- return body
+ def units_chart(self, pid):
+ overall_chart = google_chart_api.LineChart()
+
+ sales_query = db.Query(models.data.Sale)
+ sales_query.filter('pid =', pid)
+ sales_query.order('report_date')
+ sales = []
+ for sale in sales_query:
+ sales.append([sale.income_units, sale.report_date])
+ sales, dates = zip(*sales)
+
+ # Make dates readable
+ dates = [date.strftime('%d %b') for date in dates]
+
+ # Add sales line
+ overall_chart.AddLine(sales, width=line_chart.LineStyle.THICK, label='Sales')
+
+ # Determine if an upgrades line needs to be drawn
+ sales_start = sales_query.get().report_date
+ # Use settings file as the definitive source of upgrade start date because iTunes Connect sometimes reports false upgrade numbers
+ versions = settings.PRODUCTS[pid]['versions']
+ if len(versions) > 1:
+ # Convert to datetime to allow for timedelta calculation
+ upgrades_start = datetime.datetime.combine(versions[1]['date'], datetime.time(sales_start.hour, sales_start.minute))
+ difference_in_days = (upgrades_start - sales_start).days
+
+ upgrades_query = db.Query(models.data.Upgrade)
+ upgrades_query.filter('pid =', pid)
+ upgrades_query.order('report_date')
+ upgrades_query.filter('report_date >', upgrades_start)
+ upgrades = []
+
+ # Pad upgrades list with time before upgrade commenced
+ for i in range(0, difference_in_days):
+ upgrades.append(0)
+ for upgrade in upgrades_query:
+ upgrades.append(upgrade.income_units)
+
+ # Add upgrades line
+ overall_chart.AddLine(upgrades, width=line_chart.LineStyle.THICK, label='Upgrades')
+
+ # Add horizontal labels
+ max_num_horizontal_labels = 15
+ segment_gap = 1
+ if len(dates) > max_num_horizontal_labels:
+ segment_gap = len(dates) / max_num_horizontal_labels
+
+ overall_chart.bottom.min = 0
+ overall_chart.bottom.max = max_num_horizontal_labels
+ overall_chart.bottom.labels = dates
+ overall_chart.bottom.labels = dates[::segment_gap]
+
+ # Add vertical labels
+ max_num_vertical_labels = 15
+ overall_chart.left.min = 0
+ overall_chart.left.max = max(upgrades) if max(upgrades) > max(sales) else max(sales)
+ vertical_labels = []
+ segment_gap = overall_chart.left.max / max_num_vertical_labels
+ for i in range(0, max_num_vertical_labels + 1):
+ vertical_labels.append(i * segment_gap)
+ if len(vertical_labels) == max_num_vertical_labels + 1: break
+
+ overall_chart.left.labels = vertical_labels
+ overall_chart.bottom.label_gridlines = True
+
+ # Build concentrated chart if there is enough data for one
+ concentrated_chart = self.concentrated_units_chart(sales, upgrades, dates)
+ if concentrated_chart != None:
+ concentrated_chart = concentrated_chart.display.Url(1000, 300)
+
+ return (overall_chart.display.Url(1000, 300), concentrated_chart)
+
+ def concentrated_units_chart(self, sales, upgrades, dates):
+ # Want results for the last 2 weeks
+ concentrated_result_set_num = 14
+ concentrated_chart = None
+ if len(sales) > concentrated_result_set_num:
+ concentrated_chart = google_chart_api.LineChart()
+ # Slice to create the line for the concentrated chart
+ calc_concentrated_result_set = lambda x: x[len(sales) - concentrated_result_set_num :len(sales)]
+ sales_concentrated = calc_concentrated_result_set(sales)
+ dates_concentrated = calc_concentrated_result_set(dates)
+ upgrades_concentrated = calc_concentrated_result_set(upgrades)
+
+ concentrated_chart.AddLine(sales_concentrated, width=line_chart.LineStyle.THICK, label='Sales')
+ if len(upgrades_concentrated) == concentrated_result_set_num - 1:
+ concentrated_chart.AddLine(upgrades_concentrated, width=line_chart.LineStyle.THICK, label='Upgrades')
+
+ concentrated_chart.left.min = 0
+ concentrated_chart.left.max = max(upgrades_concentrated) if max(upgrades_concentrated) > max(sales_concentrated) else max(sales_concentrated)
+ segment_gap = concentrated_chart.left.max / concentrated_result_set_num
+ concentrated_vertical_labels = []
+
+ for i in range(0, concentrated_result_set_num + 1):
+ concentrated_vertical_labels.append(i * segment_gap)
+ if len(concentrated_vertical_labels) == concentrated_result_set_num + 1: break
+ if concentrated_vertical_labels[-1] < concentrated_chart.left.max:
+ new_max = concentrated_vertical_labels[-1] + segment_gap
+ concentrated_vertical_labels.append(new_max)
+ concentrated_chart.left.max = new_max
+
+ concentrated_chart.left.labels = concentrated_vertical_labels
+ concentrated_chart.bottom.labels = dates_concentrated
+ concentrated_chart.left.label_gridlines = True
+ concentrated_chart.bottom.label_gridlines = True
+ return concentrated_chart
+ else:
+ return None
def send_email(self, pid, subject, email_body):
message = mail.EmailMessage(sender=settings.PRODUCTS[pid]['from_address'],
subject=subject)
message.to = settings.PRODUCTS[pid]['to_addresses']
- message.body = email_body
+ message.html = email_body
message.send()
View
@@ -0,0 +1 @@
+__version__='1.0'
View
@@ -0,0 +1,51 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.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://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Run all tests from *_test.py files."""
+
+import os
+import unittest
+
+
+def ModuleName(filename, base_dir):
+ """Given a filename, convert to the python module name."""
+ filename = filename.replace(base_dir, '')
+ filename = filename.lstrip(os.path.sep)
+ filename = filename.replace(os.path.sep, '.')
+ if filename.endswith('.py'):
+ filename = filename[:-3]
+ return filename
+
+
+def FindTestModules():
+ """Return names of any test modules (*_test.py)."""
+ tests = []
+ start_dir = os.path.dirname(os.path.abspath(__file__))
+ for dir, subdirs, files in os.walk(start_dir):
+ if dir.endswith('/.svn') or '/.svn/' in dir:
+ continue
+ tests.extend(ModuleName(os.path.join(dir, f), start_dir) for f
+ in files if f.endswith('_test.py'))
+ return tests
+
+
+def AllTests():
+ suites = unittest.defaultTestLoader.loadTestsFromNames(FindTestModules())
+ return unittest.TestSuite(suites)
+
+
+if __name__ == '__main__':
+ unittest.main(module=None, defaultTest='__main__.AllTests')
No changes.
@@ -0,0 +1,50 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.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://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Backend which can generate charts using the Google Chart API."""
+
+from graphy import line_chart
+from graphy import bar_chart
+from graphy import pie_chart
+from graphy.backends.google_chart_api import encoders
+
+def _GetChartFactory(chart_class, display_class):
+ """Create a factory method for instantiating charts with displays.
+
+ Returns a method which, when called, will create & return a chart with
+ chart.display already populated.
+ """
+ def Inner(*args, **kwargs):
+ chart = chart_class(*args, **kwargs)
+ chart.display = display_class(chart)
+ return chart
+ return Inner
+
+# These helper methods make it easy to get chart objects with display
+# objects already setup. For example, this:
+# chart = google_chart_api.LineChart()
+# is equivalent to:
+# chart = line_chart.LineChart()
+# chart.display = google_chart_api.encoders.LineChartEncoder(chart)
+#
+# (If there's some chart type for which a helper method isn't available, you
+# can always just instantiate the correct encoder manually, like in the 2nd
+# example above).
+# TODO: fix these so they have nice docs in ipython (give them __doc__)
+LineChart = _GetChartFactory(line_chart.LineChart, encoders.LineChartEncoder)
+Sparkline = _GetChartFactory(line_chart.Sparkline, encoders.SparklineEncoder)
+BarChart = _GetChartFactory(bar_chart.BarChart, encoders.BarChartEncoder)
+PieChart = _GetChartFactory(pie_chart.PieChart, encoders.PieChartEncoder)
Oops, something went wrong.

0 comments on commit 3e549ae

Please sign in to comment.