diff --git a/src/calibre/trac/donations/server.py b/src/calibre/trac/donations/server.py index 2afc3adeca..3bf822b893 100644 --- a/src/calibre/trac/donations/server.py +++ b/src/calibre/trac/donations/server.py @@ -6,7 +6,7 @@ __docformat__ = 'restructuredtext en' ''' Keep track of donations to calibre. ''' -import sys, cStringIO, textwrap, traceback, re, os, time +import sys, cStringIO, textwrap, traceback, re, os, time, calendar from datetime import date, timedelta from math import sqrt os.environ['HOME'] = '/tmp' @@ -15,7 +15,6 @@ matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.dates as mdates - import cherrypy from lxml import etree @@ -33,7 +32,13 @@ def range_for_month(year, month): def range_for_year(year): return date(year=year, month=1, day=1), date(year=year, month=12, day=31) - + +def days_in_month(year, month): + c = calendar.Calendar() + ans = 0 + for x in c.itermonthdays(year, month): + if x != 0: ans += 1 + return ans def rationalize_country(country): if re.match('(?i)(US|USA|America)', country): @@ -67,14 +72,14 @@ def rationalize_country(country): return country class Record(object): - + def __init__(self, email, country, amount, date, name): self.email = email self.country = country self.amount = amount self.date = date self.name = name - + def __str__(self): return ''%\ (self.email, self.country, self.amount, self.date.isoformat(), 'name="%s"'%self.name if self.name else '') @@ -93,7 +98,7 @@ class Country(list): def __str__(self): return self.name + ': %.2f%%'%self.percent - + def __cmp__(self, other): return cmp(self.total, other.total) @@ -118,7 +123,7 @@ class Stats: if r.date not in self.days.keys(): self.days[r.date] = [] self.days[r.date].append(r) - + self.min, self.max = start, end self.period = (self.max - self.min) + timedelta(days=1) daily_totals = [] @@ -139,7 +144,24 @@ class Stats: self.countries[r.country].append(r) for country in self.countries.values(): country.percent = (100 * country.total/self.total) if self.total else 0. - + + def get_daily_averages(self): + month_buckets, month_order = {}, [] + x = self.min + for t in self.daily_totals: + month = (x.year, x.month) + if month not in month_buckets: + month_buckets[month] = 0. + month_order.append(month) + month_buckets[month] += t + x += timedelta(days=1) + c = calendar.Calendar() + month_days = [days_in_month(*x) for x in month_order] + month_averages = [month_buckets[x]/float(y) for x, y in zip(month_order, month_days)] + return month_order, month_averages + + + def __str__(self): buf = cStringIO.StringIO() print >>buf, '\tTotal: %.2f'%self.total @@ -149,7 +171,7 @@ class Stats: for c in self.countries.values(): print >>buf, '\t\t', c return buf.getvalue() - + def to_html(self, num_of_countries=sys.maxint): countries = sorted(self.countries.values(), cmp=cmp, reverse=True)[:num_of_countries] crows = ['%s%.2f %%'%(c.name, c.percent) for c in countries] @@ -168,32 +190,48 @@ class Stats:
%(ctable)s - ''')%dict(total=self.total, da=self.daily_average, ac=self.average, + ''')%dict(total=self.total, da=self.daily_average, ac=self.average, ctable=ctable, period=self.period.days, num=len(self.totals), - dd=self.daily_deviation, ad=self.average_deviation, - dpd=len(self.totals)/float(self.period.days), + dd=self.daily_deviation, ad=self.average_deviation, + dpd=len(self.totals)/float(self.period.days), min=self.min.isoformat(), max=self.max.isoformat()) - + def expose(func): - + def do(self, *args, **kwargs): dict.update(cherrypy.response.headers, {'Server':'Donations_server/1.0'}) return func(self, *args, **kwargs) - + return cherrypy.expose(do) class Server(object): - + TRENDS = '/tmp/donations_trend.png' MONTH_TRENDS = '/tmp/donations_month_trend.png' - + AVERAGES = '/tmp/donations_averages.png' + def __init__(self, apache=False, root='/', data_file='/tmp/donations.xml'): self.apache = apache self.document_root = root self.data_file = data_file self.read_records() - + + def calculate_daily_averages(self): + stats = self.get_slice(self.earliest, self.latest) + fig = plt.figure(2, (10, 4), 96)#, facecolor, edgecolor, frameon, FigureClass) + fig.clear() + ax = fig.add_subplot(111) + month_order, month_averages = stats.get_daily_averages() + x = [date(y, m, 1) for y, m in month_order[:-1]] + ax.plot(x, month_averages[:-1]) + ax.set_xlabel('Month') + ax.set_ylabel('Daily average ($)') + ax.xaxis.set_major_locator(mdates.MonthLocator(interval=2)) + ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%y')) + fig.savefig(self.AVERAGES) + + def calculate_month_trend(self, days=31): stats = self.get_slice(date.today()-timedelta(days=days-1), date.today()) fig = plt.figure(2, (10, 4), 96)#, facecolor, edgecolor, frameon, FigureClass) @@ -214,14 +252,14 @@ Total: $%(total).2f Daily average: $%(da).2f \u00b1 %(dd).2f Average contribution: $%(ac).2f \u00b1 %(ad).2f Donors per day: %(dpd).2f - '''%dict(total=stats.total, da=stats.daily_average, + '''%dict(total=stats.total, da=stats.daily_average, dd=stats.daily_deviation, ac=stats.average, ad=stats.average_deviation, dpd=len(stats.totals)/float(stats.period.days), ) text = ax.annotate(text, (0.5, 0.65), textcoords='axes fraction') fig.savefig(self.MONTH_TRENDS) - + def calculate_trend(self): def months(start, end): pos = range_for_month(start.year, start.month)[0] @@ -253,7 +291,7 @@ Donors per day: %(dpd).2f fig.savefig(self.TRENDS) #plt.show() - + def read_records(self): self.tree = etree.parse(self.data_file) self.last_read_time = time.time() @@ -269,21 +307,22 @@ Donors per day: %(dpd).2f self.earliest, self.latest = min_date, max_date self.calculate_trend() self.calculate_month_trend() - + self.calculate_daily_averages() + def get_slice(self, start_date, end_date): stats = Stats([r for r in self.records if r.date >= start_date and r.date <= end_date], start_date, end_date) return stats - + def month(self, year, month): return self.get_slice(*range_for_month(year, month)) - + def year(self, year): return self.get_slice(*range_for_year(year)) - + def range_to_date(self, raw): return date(*map(int, raw.split('-'))) - + def build_page(self, period_type, data): if os.stat(self.data_file).st_mtime >= self.last_read_time: self.read_records() @@ -294,7 +333,7 @@ Donors per day: %(dpd).2f yy = data if period_type == 'year' else year rl = data[0] if period_type == 'range' else '' rr = data[1] if period_type == 'range' else '' - + def build_month_list(current): months = [] for i in range(1, 13): @@ -302,7 +341,7 @@ Donors per day: %(dpd).2f sel = 'selected="selected"' if i == current else '' months.append(''%(i, sel, month)) return months - + def build_year_list(current): all_years = sorted(range(self.earliest.year, self.latest.year+1, 1)) if current not in all_years: @@ -312,11 +351,11 @@ Donors per day: %(dpd).2f sel = 'selected="selected"' if year == current else '' years.append(''%(year, sel, year)) return years - + mmlist = ''%('\n'.join(build_month_list(mm))) mylist = ''%('\n'.join(build_year_list(my))) yylist = ''%('\n'.join(build_year_list(yy))) - + if period_type == 'month': range_stats = range_for_month(my, mm) elif period_type == 'year': @@ -332,9 +371,9 @@ Donors per day: %(dpd).2f range_stats = '
Invalid input:\n%s
'%err else: range_stats = self.get_slice(*range_stats).to_html(num_of_countries=10) - + today = self.get_slice(date.today(), date.today()) - + return textwrap.dedent('''\ @@ -365,7 +404,7 @@ Donors per day: %(dpd).2f if ((dayobj.getMonth()+1!=monthfield)||(dayobj.getDate()!=dayfield)||(dayobj.getFullYear()!=yearfield)) return false; return true; } - + function check_period_form(form) { if (form.period_type[2].checked) { if (!test_date(form.range_left.value)) { @@ -381,11 +420,11 @@ Donors per day: %(dpd).2f } return true; } - + function is_empty(val) { return val.trim().length == 0 } - + function check_add_form(form) { var test_amount = /[\.0-9]+/; if (is_empty(form.email.value)) { @@ -409,8 +448,8 @@ Donors per day: %(dpd).2f return false; } return true; - } - + } + function rationalize_periods() { var form = document.forms[0]; var disabled = !form.period_type[0].checked; @@ -438,7 +477,7 @@ Donors per day: %(dpd).2f

Donations to date

%(todate)s - +

Donations in period

@@ -463,10 +502,13 @@ Donors per day: %(dpd).2f
-

Income trends for the last year

Income trends -

Income trends for the last 31 days

+

Income trends for the last year

Month income trend +

Income trends for the last 31 days

+ Daily average
+                    income trend +

Income trends since records started

@@ -479,26 +521,31 @@ Donors per day: %(dpd).2f rl=rl, rr=rr, range_stats=range_stats, root=self.document_root, today=today.total ) - + @expose def index(self): month = date.today().month year = date.today().year cherrypy.response.headers['Content-Type'] = 'application/xhtml+xml' return self.build_page('month', (year, month)) - + @expose def trend_png(self): cherrypy.response.headers['Content-Type'] = 'image/png' return open(self.TRENDS, 'rb').read() - + @expose def month_trend_png(self): cherrypy.response.headers['Content-Type'] = 'image/png' return open(self.MONTH_TRENDS, 'rb').read() - + @expose - def show(self, period_type='month', month_month='', month_year='', + def average_trend_png(self): + cherrypy.response.headers['Content-Type'] = 'image/png' + return open(self.AVERAGES, 'rb').read() + + @expose + def show(self, period_type='month', month_month='', month_year='', year_year='', range_left='', range_right=''): if period_type == 'month': mm = int(month_month) if month_month else date.today().month @@ -510,7 +557,7 @@ Donors per day: %(dpd).2f data = (range_left, range_right) cherrypy.response.headers['Content-Type'] = 'application/xhtml+xml' return self.build_page(period_type, data) - + def config(): config = { 'global': { @@ -527,9 +574,9 @@ def apache_start(): 'environment' : 'production', 'show_tracebacks' : False, }) - cherrypy.tree.mount(Server(apache=True, root='/donations/', data_file='/var/www/calibre.kovidgoyal.net/donations.xml'), + cherrypy.tree.mount(Server(apache=True, root='/donations/', data_file='/var/www/calibre.kovidgoyal.net/donations.xml'), '/donations', config=config()) - + def main(args=sys.argv): server = Server()