From: David Read Date: Tue, 06 Nov 2012 17:56:34 +0000 Subject: Show top datasets cross-publisher. Drop-down for the publisher. Browser version numbers filtered on download, so you get this version in the CSV too - for privacy. single_popular_dataset now copes when not much data, and can return the figures so DGU can reskin it in its own repo. Notes about usage stats centralised to notes.html. X-Git-Url: http://maxious.lambdacomplex.org/git/?p=ckanext-ga-report.git&a=commitdiff&h=ecca7ac98f009c6167463f366fb0b82783b5bae9 --- Show top datasets cross-publisher. Drop-down for the publisher. Browser version numbers filtered on download, so you get this version in the CSV too - for privacy. single_popular_dataset now copes when not much data, and can return the figures so DGU can reskin it in its own repo. Notes about usage stats centralised to notes.html. --- --- a/README.rst +++ b/README.rst @@ -31,10 +31,8 @@ 2. Ensure you development.ini (or similar) contains the info about your Google Analytics account and configuration:: googleanalytics.id = UA-1010101-1 - googleanalytics.account = Account name (i.e. data.gov.uk, see top level item at https://www.google.com/analytics) + googleanalytics.account = Account name (e.g. data.gov.uk, see top level item at https://www.google.com/analytics) ga-report.period = monthly - - Note that your credentials will be readable by system administrators on your server. Rather than use sensitive account details, it is suggested you give access to the GA account to a new Google account that you create just for this purpose. 3. Set up this extension's database tables using a paster command. (Ensure your CKAN pyenv is still activated, run the command from ``src/ckanext-ga-report``, alter the ``--config`` option to point to your site config file):: @@ -43,6 +41,12 @@ 4. Enable the extension in your CKAN config file by adding it to ``ckan.plugins``:: ckan.plugins = ga-report + +Problem shooting +---------------- + +* ``(ProgrammingError) relation "ga_url" does not exist`` + This means that the ``paster initdb`` step has not been run successfully. Refer to the installation instructions for this extension. Authorization @@ -79,7 +83,7 @@ Tutorial -------- -Download some GA data and store it in CKAN's db. (Ensure your CKAN pyenv is still activated, run the command from ``src/ckanext-ga-report``, alter the ``--config`` option to point to your site config file) and specifying the name of your auth file (token.dat by default) from the previous step:: +Download some GA data and store it in CKAN's database. (Ensure your CKAN pyenv is still activated, run the command from ``src/ckanext-ga-report``, alter the ``--config`` option to point to your site config file) and specifying the name of your auth file (token.dat by default) from the previous step:: $ paster loadanalytics token.dat latest --config=../ckan/development.ini --- a/ckanext/ga_report/command.py +++ b/ckanext/ga_report/command.py @@ -73,6 +73,14 @@ max_args = 2 min_args = 1 + def __init__(self, name): + super(LoadAnalytics, self).__init__(name) + self.parser.add_option('-d', '--delete-first', + action='store_true', + default=False, + dest='delete_first', + help='Delete data for the period first') + def command(self): self._load_config() @@ -83,10 +91,11 @@ svc = init_service(self.args[0], None) except TypeError: print ('Have you correctly run the getauthtoken task and ' - 'specified the correct file here') + 'specified the correct token file?') return - downloader = DownloadAnalytics(svc, profile_id=get_profile_id(svc)) + downloader = DownloadAnalytics(svc, profile_id=get_profile_id(svc), + delete_first=self.options.delete_first) time_period = self.args[1] if self.args and len(self.args) > 1 \ else 'latest' --- a/ckanext/ga_report/controller.py +++ b/ckanext/ga_report/controller.py @@ -1,11 +1,15 @@ +import re +import csv +import sys import logging import operator -from ckan.lib.base import BaseController, c, render, request, response, abort +import collections +from ckan.lib.base import (BaseController, c, g, render, request, response, abort) import sqlalchemy from sqlalchemy import func, cast, Integer import ckan.model as model -from ga_model import GA_Url, GA_Stat +from ga_model import GA_Url, GA_Stat, GA_ReferralStat log = logging.getLogger('ckanext.ga-report') @@ -30,11 +34,13 @@ def csv(self, month): import csv - entries = model.Session.query(GA_Stat).\ - filter(GA_Stat.period_name==month).\ - order_by('GA_Stat.stat_name, GA_Stat.key').all() + q = model.Session.query(GA_Stat) + if month != 'all': + q = q.filter(GA_Stat.period_name==month) + entries = q.order_by('GA_Stat.period_name, GA_Stat.stat_name, GA_Stat.key').all() response.headers['Content-Type'] = "text/csv; charset=utf-8" + response.headers['Content-Disposition'] = str('attachment; filename=stats_%s.csv' % (month,)) writer = csv.writer(response) writer.writerow(["Period", "Statistic", "Key", "Value"]) @@ -52,87 +58,268 @@ c.months = _month_details(GA_Stat) # Work out which month to show, based on query params of the first item - c.month = request.params.get('month', c.months[0][0] if c.months else '') - c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) - - entries = model.Session.query(GA_Stat).\ - filter(GA_Stat.stat_name=='Totals').\ - filter(GA_Stat.period_name==c.month).\ - order_by('ga_stat.key').all() - - c.global_totals = [] - for e in entries: - val = e.value - if e.key in ['Average time on site', 'Pages per visit', 'Percent new visits']: - val = "%.2f" % round(float(e.value), 2) - if e.key == 'Average time on site': + c.month_desc = 'all months' + c.month = request.params.get('month', '') + if c.month: + c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) + + q = model.Session.query(GA_Stat).\ + filter(GA_Stat.stat_name=='Totals') + if c.month: + q = q.filter(GA_Stat.period_name==c.month) + entries = q.order_by('ga_stat.key').all() + + def clean_key(key, val): + if key in ['Average time on site', 'Pages per visit', 'New visits']: + val = "%.2f" % round(float(val), 2) + if key == 'Average time on site': mins, secs = divmod(float(val), 60) hours, mins = divmod(mins, 60) val = '%02d:%02d:%02d (%s seconds) ' % (hours, mins, secs, val) - e.key = '%s *' % e.key - c.global_totals.append((e.key, val)) - + if key == 'New visits': + val = "%s%%" % val + if key in ['Bounces', 'Total page views', 'Total visits']: + val = int(val) + + return key, val + + c.global_totals = [] + if c.month: + for e in entries: + key, val = clean_key(e.key, e.value) + c.global_totals.append((key, val)) + else: + d = collections.defaultdict(list) + for e in entries: + d[e.key].append(float(e.value)) + for k, v in d.iteritems(): + if k in ['Bounces', 'Total page views', 'Total visits']: + v = sum(v) + else: + v = float(sum(v))/len(v) + key, val = clean_key(k,v) + c.global_totals.append((key, val)) + c.global_totals = sorted(c.global_totals, key=operator.itemgetter(0)) keys = { - 'Browser versions': 'browsers', - 'Operating Systems versions': 'os', + 'Browser versions': 'browser_versions', + 'Browsers': 'browsers', + 'Operating Systems versions': 'os_versions', + 'Operating Systems': 'os', 'Social sources': 'social_networks', 'Languages': 'languages', 'Country': 'country' } + def shorten_name(name, length=60): + return (name[:length] + '..') if len(name) > 60 else name + + def fill_out_url(url): + import urlparse + return urlparse.urljoin(g.site_url, url) + + c.social_referrer_totals, c.social_referrers = [], [] + q = model.Session.query(GA_ReferralStat) + q = q.filter(GA_ReferralStat.period_name==c.month) if c.month else q + q = q.order_by('ga_referrer.count::int desc') + for entry in q.all(): + c.social_referrers.append((shorten_name(entry.url), fill_out_url(entry.url), + entry.source,entry.count)) + + q = model.Session.query(GA_ReferralStat.url, + func.sum(GA_ReferralStat.count).label('count')) + q = q.filter(GA_ReferralStat.period_name==c.month) if c.month else q + q = q.order_by('count desc').group_by(GA_ReferralStat.url) + for entry in q.all(): + c.social_referrer_totals.append((shorten_name(entry[0]), fill_out_url(entry[0]),'', + entry[1])) + + + browser_version_re = re.compile("(.*)\((.*)\)") for k, v in keys.iteritems(): - entries = model.Session.query(GA_Stat).\ - filter(GA_Stat.stat_name==k).\ - filter(GA_Stat.period_name==c.month).\ - order_by('ga_stat.value::int desc').all() - setattr(c, v, [(s.key, s.value) for s in entries ]) - + + def clean_field(key): + if k != 'Browser versions': + return key + m = browser_version_re.match(key) + browser = m.groups()[0].strip() + ver = m.groups()[1] + parts = ver.split('.') + if len(parts) > 1: + if parts[1][0] == '0': + ver = parts[0] + else: + ver = "%s.%s" % (parts[0],parts[1]) + if browser in ['Safari','Android Browser']: # Special case complex version nums + ver = parts[0] + if len(ver) > 2: + ver = "%s%sX" % (ver[0], ver[1]) + + return "%s (%s)" % (browser, ver,) + + q = model.Session.query(GA_Stat).\ + filter(GA_Stat.stat_name==k) + if c.month: + entries = [] + q = q.filter(GA_Stat.period_name==c.month).\ + order_by('ga_stat.value::int desc') + + d = collections.defaultdict(int) + for e in q.all(): + d[e.key] += int(e.value) + entries = [] + for key, val in d.iteritems(): + entries.append((key,val,)) + entries = sorted(entries, key=operator.itemgetter(1), reverse=True) + + def percent(num, total): + p = 100 * float(num)/float(total) + return "%.2f%%" % round(p, 2) + + # Get the total for each set of values and then set the value as + # a percentage of the total + if k == 'Social sources': + total = sum([x for n,x in c.global_totals if n == 'Total visits']) + else: + total = sum([num for _,num in entries]) + setattr(c, v, [(k,percent(v,total)) for k,v in entries ]) return render('ga_report/site/index.html') -class GaPublisherReport(BaseController): +class GaDatasetReport(BaseController): """ - Displays the pageview and visit count for specific publishers based on - the datasets associated with the publisher. + Displays the pageview and visit count for datasets + with options to filter by publisher and time period. """ - - def index(self): + def publisher_csv(self, month): + ''' + Returns a CSV of each publisher with the total number of dataset + views & visits. + ''' + c.month = month if not month == 'all' else '' + response.headers['Content-Type'] = "text/csv; charset=utf-8" + response.headers['Content-Disposition'] = str('attachment; filename=publishers_%s.csv' % (month,)) + + writer = csv.writer(response) + writer.writerow(["Publisher Title", "Publisher Name", "Views", "Visits", "Period Name"]) + + for publisher,view,visit in _get_top_publishers(None): + writer.writerow([publisher.title.encode('utf-8'), + publisher.name.encode('utf-8'), + view, + visit, + month]) + + def dataset_csv(self, id='all', month='all'): + ''' + Returns a CSV with the number of views & visits for each dataset. + + :param id: A Publisher ID or None if you want for all + :param month: The time period, or 'all' + ''' + c.month = month if not month == 'all' else '' + if id != 'all': + c.publisher = model.Group.get(id) + if not c.publisher: + abort(404, 'A publisher with that name could not be found') + + packages = self._get_packages(c.publisher) + response.headers['Content-Type'] = "text/csv; charset=utf-8" + response.headers['Content-Disposition'] = \ + str('attachment; filename=datasets_%s_%s.csv' % (c.publisher_name, month,)) + + writer = csv.writer(response) + writer.writerow(["Dataset Title", "Dataset Name", "Views", "Visits", "Period Name"]) + + for package,view,visit in packages: + writer.writerow([package.title.encode('utf-8'), + package.name.encode('utf-8'), + view, + visit, + month]) + + def publishers(self): + '''A list of publishers and the number of views/visits for each''' # Get the month details by fetching distinct values and determining the # month names from the values. c.months = _month_details(GA_Url) # Work out which month to show, based on query params of the first item - c.month = request.params.get('month', c.months[0][0] if c.months else '') - c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) - - connection = model.Session.connection() - q = """ - select department_id, sum(pageviews::int) views, sum(visitors::int) visits - from ga_url - where department_id <> '' - and period_name=%s - group by department_id order by views desc limit 20; - """ - # Add this back (before and period_name =%s) if you want to ignore publisher - # homepage views - # and not url like '/publisher/%%' - - c.top_publishers = [] - res = connection.execute(q, c.month) - for row in res: - c.top_publishers.append((model.Group.get(row[0]), row[1], row[2])) + c.month = request.params.get('month', '') + c.month_desc = 'all months' + if c.month: + c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) + + c.top_publishers = _get_top_publishers() return render('ga_report/publisher/index.html') - - def read(self, id): - - c.publisher = model.Group.get(id) - if not c.publisher: - abort(404, 'A publisher with that name could not be found') + def _get_packages(self, publisher=None, count=-1): + '''Returns the datasets in order of visits''' + if count == -1: + count = sys.maxint + + q = model.Session.query(GA_Url)\ + .filter(GA_Url.url.like('/dataset/%')) + if publisher: + q = q.filter(GA_Url.department_id==publisher.name) + if c.month: + q = q.filter(GA_Url.period_name==c.month) + q = q.order_by('ga_url.visitors::int desc') + + if c.month: + top_packages = [] + for entry in q.limit(count): + package_name = entry.url[len('/dataset/'):] + p = model.Package.get(package_name) + if p: + top_packages.append((p, entry.pageviews, entry.visitors)) + else: + log.warning('Could not find package "%s"', package_name) + else: + ds = {} + for entry in q: + if len(ds) >= count: + break + package_name = entry.url[len('/dataset/'):] + p = model.Package.get(package_name) + if p: + if not p in ds: + ds[p] = {'views': 0, 'visits': 0} + ds[p]['views'] = ds[p]['views'] + int(entry.pageviews) + ds[p]['visits'] = ds[p]['visits'] + int(entry.visitors) + else: + log.warning('Could not find package "%s"', package_name) + + results = [] + for k, v in ds.iteritems(): + results.append((k,v['views'],v['visits'])) + + top_packages = sorted(results, key=operator.itemgetter(1), reverse=True) + return top_packages + + def read(self): + ''' + Lists the most popular datasets across all publishers + ''' + return self.read_publisher(None) + + def read_publisher(self, id): + ''' + Lists the most popular datasets for a publisher (or across all publishers) + ''' + count = 20 + + c.publishers = _get_publishers() + + id = request.params.get('publisher', id) + if id and id != 'all': + c.publisher = model.Group.get(id) + if not c.publisher: + abort(404, 'A publisher with that name could not be found') + c.publisher_name = c.publisher.name c.top_packages = [] # package, dataset_views in c.top_packages # Get the month details by fetching distinct values and determining the @@ -140,22 +327,69 @@ c.months = _month_details(GA_Url) # Work out which month to show, based on query params of the first item - c.month = request.params.get('month', c.months[0][0] if c.months else '') - c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) - - entry = model.Session.query(GA_Url).\ - filter(GA_Url.url=='/publisher/%s' % c.publisher.name).\ - filter(GA_Url.period_name==c.month).first() - c.publisher_page_views = entry.pageviews if entry else 0 - - entries = model.Session.query(GA_Url).\ - filter(GA_Url.department_id==c.publisher.name).\ - filter(GA_Url.period_name==c.month).\ - order_by('ga_url.pageviews::int desc')[:20] - for entry in entries: - if entry.url.startswith('/dataset/'): - p = model.Package.get(entry.url[len('/dataset/'):]) - c.top_packages.append((p,entry.pageviews,entry.visitors)) + c.month = request.params.get('month', '') + if not c.month: + c.month_desc = 'all months' + else: + c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) + + c.publisher_page_views = 0 + q = model.Session.query(GA_Url).\ + filter(GA_Url.url=='/publisher/%s' % c.publisher_name) + if c.month: + entry = q.filter(GA_Url.period_name==c.month).first() + c.publisher_page_views = entry.pageviews if entry else 0 + else: + for e in q.all(): + c.publisher_page_views = c.publisher_page_views + int(e.pageviews) + + c.top_packages = self._get_packages(c.publisher, 20) return render('ga_report/publisher/read.html') +def _get_top_publishers(limit=20): + ''' + Returns a list of the top 20 publishers by dataset visits. + (The number to show can be varied with 'limit') + ''' + connection = model.Session.connection() + q = """ + select department_id, sum(pageviews::int) views, sum(visitors::int) visits + from ga_url + where department_id <> ''""" + if c.month: + q = q + """ + and period_name=%s + """ + q = q + """ + group by department_id order by visits desc + """ + if limit: + q = q + " limit %s;" % (limit) + + # Add this back (before and period_name =%s) if you want to ignore publisher + # homepage views + # and not url like '/publisher/%%' + + top_publishers = [] + res = connection.execute(q, c.month) + + for row in res: + g = model.Group.get(row[0]) + if g: + top_publishers.append((g, row[1], row[2])) + return top_publishers + +def _get_publishers(): + ''' + Returns a list of all publishers. Each item is a tuple: + (names, title) + ''' + publishers = [] + for pub in model.Session.query(model.Group).\ + filter(model.Group.type=='publisher').\ + filter(model.Group.state=='active').\ + order_by(model.Group.name): + publishers.append((pub.name, pub.title)) + return publishers + --- a/ckanext/ga_report/download_analytics.py +++ b/ckanext/ga_report/download_analytics.py @@ -1,7 +1,7 @@ import os import logging import datetime - +import collections from pylons import config import ga_model @@ -11,15 +11,17 @@ log = logging.getLogger('ckanext.ga-report') FORMAT_MONTH = '%Y-%m' +MIN_VIEWS = 50 +MIN_VISITS = 20 class DownloadAnalytics(object): '''Downloads and stores analytics info''' - def __init__(self, service=None, profile_id=None): + def __init__(self, service=None, profile_id=None, delete_first=False): self.period = config['ga-report.period'] self.service = service self.profile_id = profile_id - + self.delete_first = delete_first def specific_month(self, date): import calendar @@ -90,11 +92,14 @@ def download_and_store(self, periods): for period_name, period_complete_day, start_date, end_date in periods: + if self.delete_first: + log.info('Deleting existing Analytics for period "%s"', + period_name) + ga_model.delete(period_name) log.info('Downloading Analytics for period "%s" (%s - %s)', self.get_full_period_name(period_name, period_complete_day), start_date.strftime('%Y %m %d'), end_date.strftime('%Y %m %d')) - data = self.download(start_date, end_date, '~/dataset/[a-z0-9-_]+') log.info('Storing Dataset Analytics for period "%s"', self.get_full_period_name(period_name, period_complete_day)) @@ -107,6 +112,33 @@ ga_model.update_publisher_stats(period_name) # about 30 seconds. self.sitewide_stats( period_name ) + + self.update_social_info(period_name, start_date, end_date) + + def update_social_info(self, period_name, start_date, end_date): + start_date = start_date.strftime('%Y-%m-%d') + end_date = end_date.strftime('%Y-%m-%d') + query = 'ga:hasSocialSourceReferral=~Yes$' + metrics = 'ga:entrances' + sort = '-ga:entrances' + + # Supported query params at + # https://developers.google.com/analytics/devguides/reporting/core/v3/reference + results = self.service.data().ga().get( + ids='ga:' + self.profile_id, + filters=query, + start_date=start_date, + metrics=metrics, + sort=sort, + dimensions="ga:landingPagePath,ga:socialNetwork", + max_results=10000, + end_date=end_date).execute() + data = collections.defaultdict(list) + rows = results.get('rows',[]) + for row in rows: + from ga_model import _normalize_url + data[_normalize_url(row[0])].append( (row[1], int(row[2]),) ) + ga_model.update_social(period_name, data) def download(self, start_date, end_date, path='~/dataset/[a-z0-9-_]+'): @@ -176,12 +208,12 @@ max_results=10000, end_date=end_date).execute() result_data = results.get('rows') - ga_model.update_sitewide_stats(period_name, "Totals", {'Total pageviews': result_data[0][0]}) - - results = self.service.data().ga().get( - ids='ga:' + self.profile_id, - start_date=start_date, - metrics='ga:pageviewsPerVisit,ga:bounces,ga:avgTimeOnSite,ga:percentNewVisits', + ga_model.update_sitewide_stats(period_name, "Totals", {'Total page views': result_data[0][0]}) + + results = self.service.data().ga().get( + ids='ga:' + self.profile_id, + start_date=start_date, + metrics='ga:pageviewsPerVisit,ga:bounces,ga:avgTimeOnSite,ga:percentNewVisits,ga:visitors', max_results=10000, end_date=end_date).execute() result_data = results.get('rows') @@ -189,7 +221,8 @@ 'Pages per visit': result_data[0][0], 'Bounces': result_data[0][1], 'Average time on site': result_data[0][2], - 'Percent new visits': result_data[0][3], + 'New visits': result_data[0][3], + 'Total visits': result_data[0][4], } ga_model.update_sitewide_stats(period_name, "Totals", data) @@ -208,11 +241,13 @@ data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) ga_model.update_sitewide_stats(period_name, "Languages", data) data = {} for result in result_data: data[result[1]] = data.get(result[1], 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) ga_model.update_sitewide_stats(period_name, "Country", data) @@ -227,13 +262,11 @@ max_results=10000, end_date=end_date).execute() result_data = results.get('rows') - twitter_links = [] data = {} for result in result_data: if not result[0] == '(not set)': data[result[0]] = data.get(result[0], 0) + int(result[2]) - if result[0] == 'Twitter': - twitter_links.append(result[1]) + self._filter_out_long_tail(data, 3) ga_model.update_sitewide_stats(period_name, "Social sources", data) @@ -251,12 +284,14 @@ data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) ga_model.update_sitewide_stats(period_name, "Operating Systems", data) data = {} for result in result_data: - key = "%s (%s)" % (result[0],result[1]) - data[key] = result[2] + if int(result[2]) >= MIN_VIEWS: + key = "%s %s" % (result[0],result[1]) + data[key] = result[2] ga_model.update_sitewide_stats(period_name, "Operating Systems versions", data) @@ -271,17 +306,42 @@ max_results=10000, end_date=end_date).execute() result_data = results.get('rows') + # e.g. [u'Firefox', u'19.0', u'20'] + data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) ga_model.update_sitewide_stats(period_name, "Browsers", data) data = {} for result in result_data: - key = "%s (%s)" % (result[0], result[1]) - data[key] = result[2] + key = "%s %s" % (result[0], self._filter_browser_version(result[0], result[1])) + data[key] = data.get(key, 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) ga_model.update_sitewide_stats(period_name, "Browser versions", data) + @classmethod + def _filter_browser_version(cls, browser, version_str): + ''' + Simplifies a browser version string if it is detailed. + i.e. groups together Firefox 3.5.1 and 3.5.2 to be just 3. + This is helpful when viewing stats and good to protect privacy. + ''' + ver = version_str + parts = ver.split('.') + if len(parts) > 1: + if parts[1][0] == '0': + ver = parts[0] + else: + ver = "%s" % (parts[0]) + # Special case complex version nums + if browser in ['Safari', 'Android Browser']: + ver = parts[0] + if len(ver) > 2: + num_hidden_digits = len(ver) - 2 + ver = ver[0] + ver[1] + 'X' * num_hidden_digits + return ver def _mobile_stats(self, start_date, end_date, period_name): """ Info about mobile devices """ @@ -299,10 +359,23 @@ data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) ga_model.update_sitewide_stats(period_name, "Mobile brands", data) data = {} for result in result_data: data[result[1]] = data.get(result[1], 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) ga_model.update_sitewide_stats(period_name, "Mobile devices", data) + @classmethod + def _filter_out_long_tail(cls, data, threshold=10): + ''' + Given data which is a frequency distribution, filter out + results which are below a threshold count. This is good to protect + privacy. + ''' + for key, value in data.items(): + if value < threshold: + del data[key] + --- a/ckanext/ga_report/ga_auth.py +++ b/ckanext/ga_report/ga_auth.py @@ -53,7 +53,11 @@ return None accountName = config.get('googleanalytics.account') + if not accountName: + raise Exception('googleanalytics.account needs to be configured') webPropertyId = config.get('googleanalytics.id') + if not webPropertyId: + raise Exception('googleanalytics.id needs to be configured') for acc in accounts.get('items'): if acc.get('name') == accountName: accountId = acc.get('id') --- a/ckanext/ga_report/ga_model.py +++ b/ckanext/ga_report/ga_model.py @@ -13,6 +13,8 @@ def make_uuid(): return unicode(uuid.uuid4()) +metadata = MetaData() + class GA_Url(object): @@ -21,20 +23,6 @@ for k,v in kwargs.items(): setattr(self, k, v) -class GA_Stat(object): - - def __init__(self, **kwargs): - for k,v in kwargs.items(): - setattr(self, k, v) - -class GA_Publisher(object): - - def __init__(self, **kwargs): - for k,v in kwargs.items(): - setattr(self, k, v) - - -metadata = MetaData() url_table = Table('ga_url', metadata, Column('id', types.UnicodeText, primary_key=True, default=make_uuid), @@ -47,6 +35,13 @@ ) mapper(GA_Url, url_table) + +class GA_Stat(object): + + def __init__(self, **kwargs): + for k,v in kwargs.items(): + setattr(self, k, v) + stat_table = Table('ga_stat', metadata, Column('id', types.UnicodeText, primary_key=True, default=make_uuid), @@ -56,6 +51,12 @@ Column('value', types.UnicodeText), ) mapper(GA_Stat, stat_table) + +class GA_Publisher(object): + + def __init__(self, **kwargs): + for k,v in kwargs.items(): + setattr(self, k, v) pub_table = Table('ga_publisher', metadata, Column('id', types.UnicodeText, primary_key=True, @@ -71,6 +72,24 @@ mapper(GA_Publisher, pub_table) +class GA_ReferralStat(object): + + def __init__(self, **kwargs): + for k,v in kwargs.items(): + setattr(self, k, v) + +referrer_table = Table('ga_referrer', metadata, + Column('id', types.UnicodeText, primary_key=True, + default=make_uuid), + Column('period_name', types.UnicodeText), + Column('source', types.UnicodeText), + Column('url', types.UnicodeText), + Column('count', types.Integer), + ) +mapper(GA_ReferralStat, referrer_table) + + + def init_tables(): metadata.create_all(model.meta.engine) @@ -93,8 +112,9 @@ >>> normalize_url('http://data.gov.uk/dataset/weekly_fuel_prices') '/dataset/weekly_fuel_prices' ''' - url = re.sub('https?://(www\.)?data.gov.uk', '', url) - return url + # Deliberately leaving a / + url = url.replace('http:/','') + return '/' + '/'.join(url.split('/')[2:]) def _get_department_id_of_url(url): @@ -167,6 +187,33 @@ model.Session.commit() +def update_social(period_name, data): + # Clean up first. + model.Session.query(GA_ReferralStat).\ + filter(GA_ReferralStat.period_name==period_name).delete() + + for url,data in data.iteritems(): + for entry in data: + source = entry[0] + count = entry[1] + + item = model.Session.query(GA_ReferralStat).\ + filter(GA_ReferralStat.period_name==period_name).\ + filter(GA_ReferralStat.source==source).\ + filter(GA_ReferralStat.url==url).first() + if item: + item.count = item.count + count + model.Session.add(item) + else: + # create the row + values = {'id': make_uuid(), + 'period_name': period_name, + 'source': source, + 'url': url, + 'count': count, + } + model.Session.add(GA_ReferralStat(**values)) + model.Session.commit() def update_publisher_stats(period_name): """ @@ -248,3 +295,15 @@ for grandchild in go_down_tree(child): yield grandchild +def delete(period_name): + ''' + Deletes table data for the specified period, or specify 'all' + for all periods. + ''' + for object_type in (GA_Url, GA_Stat, GA_Publisher, GA_ReferralStat): + q = model.Session.query(object_type) + if period_name != 'all': + q = q.filter_by(period_name=period_name) + q.delete() + model.Session.commit() + --- a/ckanext/ga_report/helpers.py +++ b/ckanext/ga_report/helpers.py @@ -3,35 +3,74 @@ import ckan.lib.base as base import ckan.model as model +from ckanext.ga_report.ga_model import GA_Url, GA_Publisher +from ckanext.ga_report.controller import _get_publishers _log = logging.getLogger(__name__) +def popular_datasets(count=10): + import random + + publisher = None + publishers = _get_publishers(30) + total = len(publishers) + while not publisher or not datasets: + rand = random.randrange(0, total) + publisher = publishers[rand][0] + if not publisher.state == 'active': + publisher = None + continue + datasets = _datasets_for_publisher(publisher, 10)[:count] + + ctx = { + 'datasets': datasets, + 'publisher': publisher + } + return base.render_snippet('ga_report/ga_popular_datasets.html', **ctx) + +def single_popular_dataset(top=20): + '''Returns a random dataset from the most popular ones. + + :param top: the number of top datasets to select from + ''' + import random + + top_datasets = model.Session.query(GA_Url).\ + filter(GA_Url.url.like('/dataset/%')).\ + order_by('ga_url.pageviews::int desc') + num_top_datasets = top_datasets.count() + + if num_top_datasets: + dataset = None + while not dataset: + rand = random.randrange(0, min(top, num_top_datasets)) + ga_url = top_datasets[rand] + dataset = model.Package.get(ga_url.url[len('/dataset/'):]) + if dataset and not dataset.state == 'active': + dataset = None + else: + dataset = model.Session.query(model.Package)\ + .filter_by(state='active').first() + publisher = dataset.get_groups('publisher')[0] + return { + 'dataset': dataset, + 'publisher': publisher + } + +def single_popular_dataset_html(top=20): + context = single_popular_dataset(top) + return base.render_snippet('ga_report/ga_popular_single.html', **context) + + def most_popular_datasets(publisher, count=20): - from ckanext.ga_report.ga_model import GA_Url if not publisher: _log.error("No valid publisher passed to 'most_popular_datasets'") return "" - datasets = {} - entries = model.Session.query(GA_Url).\ - filter(GA_Url.department_id==publisher.name).\ - filter(GA_Url.url.like('/dataset/%')).\ - order_by('ga_url.pageviews::int desc')[:count] - for entry in entries: - p = model.Package.get(entry.url[len('/dataset/'):]) - if not p in datasets: - datasets[p] = {'views':0, 'visits': 0} - datasets[p]['views'] = datasets[p]['views'] + int(entry.pageviews) - datasets[p]['visits'] = datasets[p]['visits'] + int(entry.visitors) - - results = [] - for k, v in datasets.iteritems(): - results.append((k,v['views'],v['visits'])) - - results = sorted(results, key=operator.itemgetter(1), reverse=True) + results = _datasets_for_publisher(publisher, count) ctx = { - 'dataset_count': len(datasets), + 'dataset_count': len(results), 'datasets': results, 'publisher': publisher @@ -39,3 +78,23 @@ return base.render_snippet('ga_report/publisher/popular.html', **ctx) +def _datasets_for_publisher(publisher, count): + datasets = {} + entries = model.Session.query(GA_Url).\ + filter(GA_Url.department_id==publisher.name).\ + filter(GA_Url.url.like('/dataset/%')).\ + order_by('ga_url.pageviews::int desc').all() + for entry in entries: + if len(datasets) < count: + p = model.Package.get(entry.url[len('/dataset/'):]) + if not p in datasets: + datasets[p] = {'views':0, 'visits': 0} + datasets[p]['views'] = datasets[p]['views'] + int(entry.pageviews) + datasets[p]['visits'] = datasets[p]['visits'] + int(entry.visitors) + + results = [] + for k, v in datasets.iteritems(): + results.append((k,v['views'],v['visits'])) + + return sorted(results, key=operator.itemgetter(1), reverse=True) + --- a/ckanext/ga_report/plugin.py +++ b/ckanext/ga_report/plugin.py @@ -2,6 +2,10 @@ import ckan.lib.helpers as h import ckan.plugins as p from ckan.plugins import implements, toolkit + +from ckanext.ga_report.helpers import (most_popular_datasets, + popular_datasets, + single_popular_dataset) log = logging.getLogger('ckanext.ga-report') @@ -19,32 +23,51 @@ A dictionary of extra helpers that will be available to provide ga report info to templates. """ - from ckanext.ga_report.helpers import most_popular_datasets return { 'ga_report_installed': lambda: True, + 'popular_datasets': popular_datasets, 'most_popular_datasets': most_popular_datasets, + 'single_popular_dataset': single_popular_dataset } def after_map(self, map): + # GaReport map.connect( - '/data/analytics/publisher', - controller='ckanext.ga_report.controller:GaPublisherReport', - action='index' - ) - map.connect( - '/data/analytics/publisher/{id}', - controller='ckanext.ga_report.controller:GaPublisherReport', - action='read' - ) - map.connect( - '/data/analytics', + '/data/site-usage', controller='ckanext.ga_report.controller:GaReport', action='index' ) map.connect( - '/data/analytics/data_{month}.csv', + '/data/site-usage/data_{month}.csv', controller='ckanext.ga_report.controller:GaReport', action='csv' + ) + + # GaDatasetReport + map.connect( + '/data/site-usage/publisher', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='publishers' + ) + map.connect( + '/data/site-usage/publishers_{month}.csv', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='publisher_csv' + ) + map.connect( + '/data/site-usage/dataset/datasets_{id}_{month}.csv', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='dataset_csv' + ) + map.connect( + '/data/site-usage/dataset', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='read' + ) + map.connect( + '/data/site-usage/dataset/{id}', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='read_publisher' ) return map --- a/ckanext/ga_report/report_model.py +++ /dev/null --- /dev/null +++ b/ckanext/ga_report/templates/ga_report/ga_popular_datasets.html @@ -1,1 +1,27 @@ + + + + + + + + --- /dev/null +++ b/ckanext/ga_report/templates/ga_report/ga_popular_single.html @@ -1,1 +1,31 @@ + + + + + + + + + + --- /dev/null +++ b/ckanext/ga_report/templates/ga_report/ga_util.html @@ -1,1 +1,57 @@ + + + + + + + + + + + + + + +
NameSourceVisits
${name}${source}${count}
+ + + + + + + + + + + + + +
Name% ${title}
${name}${value}
+ + +
+
+
+ +
+
+
+ + + + --- /dev/null +++ b/ckanext/ga_report/templates/ga_report/notes.html @@ -1,1 +1,17 @@ + +
  • +

    Notes

    + +
  • + + --- a/ckanext/ga_report/templates/ga_report/publisher/index.html +++ b/ckanext/ga_report/templates/ga_report/publisher/index.html @@ -3,27 +3,38 @@ xmlns:xi="http://www.w3.org/2001/XInclude" py:strip=""> - Publisher Analytics for ${g.site_title} + + + Usage by Publisher
  • -

    Publishers

    -

    The table shows the top 20 publishers as recorded by page views of datasets owned by that publisher, and the number of visits to each publisher's home page.

    +

    Download

    +

    + Download as CSV
    +

  • +
    +
    -

    Publisher Analytics

    -

    The top 20 publishers

    -
    +

    Site Usage

    + + ${usage_nav('Publishers')} + + +
    - +
    @@ -31,11 +42,11 @@ Publisher Dataset Views - Visits + Dataset Visits - ${h.link_to(publisher.title, h.url_for(controller='ckanext.ga_report.controller:GaPublisherReport', action='read', id=publisher.name))} + ${h.link_to(publisher.title, h.url_for(controller='ckanext.ga_report.controller:GaDatasetReport', action='read_publisher', id=publisher.name))} ${views} ${visits} @@ -60,5 +71,3 @@ - - --- a/ckanext/ga_report/templates/ga_report/publisher/popular.html +++ b/ckanext/ga_report/templates/ga_report/publisher/popular.html @@ -3,20 +3,19 @@ xmlns:xi="http://www.w3.org/2001/XInclude" py:strip=""> -

    We do not currently have analytics data for ${publisher.title}

    +

    We do not currently have usage data for ${publisher.title}

    --- a/ckanext/ga_report/templates/ga_report/publisher/read.html +++ b/ckanext/ga_report/templates/ga_report/publisher/read.html @@ -3,42 +3,48 @@ xmlns:xi="http://www.w3.org/2001/XInclude" py:strip=""> - Analytics for ${g.site_title} + - + Usage by Dataset +
  • -

    ${c.publisher.title}

    -

    - The table shows the top 20 most viewed datasets belonging to ${c.publisher.title}. +

    Download

    +

    + Download as CSV

    -

    - As well as showing the number of views within ${c.month_desc}, it will also show the - number of visitors that viewed each dataset. -

    -

    The dataset list page for ${c.publisher.title} was viewed ${c.publisher_page_views} times during ${c.month_desc}

    -

    View the publishers leaderboard

  • +
    +
    -

    Analytics for ${c.publisher.title}

    +

    Site Usage

    -

    Top 20 most viewed datasets

    -

    Note: this data does not include API calls

    + ${usage_nav('Datasets')} -
    -
    + +
    - -
    - + + +
    + - +

    ${c.publisher.title}

    + +

    No page views in this period

    +
    @@ -52,7 +58,7 @@ -
    Dataset Views${visits}
    +
    --- a/ckanext/ga_report/templates/ga_report/site/index.html +++ b/ckanext/ga_report/templates/ga_report/site/index.html @@ -3,40 +3,67 @@ xmlns:xi="http://www.w3.org/2001/XInclude" py:strip=""> - Site analytics + + + Site usage
  • -

    Statistics

    -

    It is possible to export the analytics data as a CSV file, which contains all of the information for ${c.month_desc}

    +

    Download

    +

    + Download as CSV
    +

  • -
  • -

    Publisher statistics

    -

    You can view statistics about specific publishers at the Publisher Analytics page

    + -
  • -

    Site statistics

    +

    Site Usage

    + ${usage_nav('Site-wide')}
    - +
    @@ -54,78 +81,34 @@ - -

    * Values are rounded up to 2 decimal places.

    -
    - - - - - - - - - - - -
    NameValue
    ${name}${value}
    -
    -
    - - - - - - - - - - - -
    NameValue
    ${name}${value}
    +
    +

    Note: Where a browser has a large number of versions, these have been grouped together.

    + ${stat_table(c.browser_versions)} +
    +
    + ${stat_table(c.browsers)} +
    +
    + ${stat_table(c.os)} +
    +
    + ${stat_table(c.os_versions)} +
    +
    +

    Number of visits that were referred from social networks

    + ${social_table(c.social_referrer_totals)}
    - - - - - - - - - - - -
    NameValue
    ${name}${value}
    +

    Percentage of visits that were referred from these social networks

    + + ${stat_table(c.social_networks, 'Visits')}
    - - - - - - - - - - - -
    NameValue
    ${name}${value}
    + ${stat_table(c.languages)}
    - - - - - - - - - - - -
    NameValue
    ${name}${value}
    + ${stat_table(c.country)}
    @@ -140,10 +123,12 @@ --- /dev/null +++ b/ckanext/ga_report/tests/test_download.py @@ -1,1 +1,30 @@ +from nose.tools import assert_equal +from ckanext.ga_report.download_analytics import DownloadAnalytics + +_filter_browser_version = DownloadAnalytics._filter_browser_version + +class TestBrowserVersionFilter: + def test_chrome(self): + assert_equal(_filter_browser_version('Chrome', u'6.0.472.0'), '6') + def test_firefox(self): + assert_equal(_filter_browser_version('Firefox', u'16.1'), '16') + def test_safari(self): + assert_equal(_filter_browser_version('Safari', u'534.55.3'), '53X') + assert_equal(_filter_browser_version('Safari', u'1534.55.3'), '15XX') + def test_ie(self): + assert_equal(_filter_browser_version('Internet Explorer', u'8.0'), '8') + def test_opera_mini(self): + assert_equal(_filter_browser_version('Opera Mini', u'6.5.27431'), '6') + def test_opera(self): + assert_equal(_filter_browser_version('Opera', u'11.60'), '11') + +class TestDownloadAnalytics: + def test_filter_out_long_tail(self): + data = {'Firefox': 100, + 'Obscure Browser': 5, + 'Chrome': 150} + DownloadAnalytics._filter_out_long_tail(data, 10) + assert_equal(data, {'Firefox': 100, + 'Chrome': 150}) + --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ entry_points=\ """ [ckan.plugins] - # A