Added per-app totals API (bug 933861)
This commit is contained in:
Родитель
f5f9039397
Коммит
f0a25bb1c6
|
@ -543,3 +543,40 @@ The gross revenue of app purchases over time.
|
|||
...
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Totals Statistics
|
||||
=================
|
||||
|
||||
Statistical information about metrics tracked. The information includes
|
||||
the total, minimum and maximum, and other statistical calculations for
|
||||
various metrics tracked.
|
||||
|
||||
Metrics
|
||||
-------
|
||||
|
||||
Provided are the following metrics.
|
||||
|
||||
Per-app totals
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
Statistical information about per-app metrics.
|
||||
|
||||
.. http:get:: /api/v1/stats/app/(int:id)|(string:slug)/totals/
|
||||
|
||||
**Response**:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"installs": {
|
||||
"max": 224.0,
|
||||
"mean": 184.80000000000001,
|
||||
"min": 132.0,
|
||||
"sum_of_squares": 692112.0,
|
||||
"std_deviation": 21.320412753978232,
|
||||
"total": 3696.0,
|
||||
"variance": 454.55999999999767
|
||||
},
|
||||
...
|
||||
}
|
||||
|
|
|
@ -94,6 +94,13 @@ APP_STATS = {
|
|||
'coerce': {'count': lambda d: '{0:.2f}'.format(d)},
|
||||
},
|
||||
}
|
||||
APP_STATS_TOTAL = {
|
||||
'installs': {
|
||||
'metric': 'app_installs',
|
||||
},
|
||||
# TODO: Add more metrics here as needed. The total API will iterate over
|
||||
# them and return statistical totals information on them all.
|
||||
}
|
||||
|
||||
|
||||
def _get_monolith_data(stat, start, end, interval, dimensions):
|
||||
|
@ -209,6 +216,69 @@ class AppStats(CORSMixin, SlugOrIdMixin, ListAPIView):
|
|||
dimensions))
|
||||
|
||||
|
||||
class AppStatsTotal(CORSMixin, SlugOrIdMixin, ListAPIView):
|
||||
authentication_classes = (RestOAuthAuthentication,
|
||||
RestSharedSecretAuthentication)
|
||||
cors_allowed_methods = ['get']
|
||||
permission_classes = [AnyOf(AllowAppOwner,
|
||||
GroupPermission('Stats', 'View'))]
|
||||
queryset = Webapp.objects.all()
|
||||
slug_field = 'app_slug'
|
||||
|
||||
def get(self, request, pk):
|
||||
app = self.get_object()
|
||||
|
||||
try:
|
||||
client = get_monolith_client()
|
||||
except requests.ConnectionError as e:
|
||||
log.info('Monolith connection error: {0}'.format(e))
|
||||
raise ServiceUnavailable
|
||||
|
||||
# Note: We have to do this as separate requests so that if one fails
|
||||
# the rest can still be returned.
|
||||
data = {}
|
||||
for metric, stat in APP_STATS_TOTAL.items():
|
||||
data[metric] = {}
|
||||
query = {
|
||||
'query': {
|
||||
'match_all': {}
|
||||
},
|
||||
'facets': {
|
||||
metric: {
|
||||
'statistical': {
|
||||
'field': stat['metric']
|
||||
},
|
||||
'facet_filter': {
|
||||
'term': {
|
||||
'app-id': app.id
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'size': 0
|
||||
}
|
||||
|
||||
try:
|
||||
resp = client.raw(query)
|
||||
except ValueError as e:
|
||||
log.info('Received value error from monolith client: %s' % e)
|
||||
continue
|
||||
|
||||
for metric, facet in resp.get('facets', {}).items():
|
||||
count = facet.get('count', 0)
|
||||
|
||||
# We filter out facets with count=0 to avoid returning things
|
||||
# like `'max': u'-Infinity'`.
|
||||
if count > 0:
|
||||
for field in ('max', 'mean', 'min', 'std_deviation',
|
||||
'sum_of_squares', 'total', 'variance'):
|
||||
value = facet.get(field)
|
||||
if value is not None:
|
||||
data[metric][field] = value
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
class TransactionAPI(CORSMixin, APIView):
|
||||
"""
|
||||
API to query by transaction ID.
|
||||
|
|
|
@ -15,20 +15,7 @@ from mkt.site.fixtures import fixture
|
|||
from mkt.stats.api import APP_STATS, STATS, _get_monolith_data
|
||||
|
||||
|
||||
@mock.patch('monolith.client.Client')
|
||||
@mock.patch.object(settings, 'MONOLITH_SERVER', 'http://0.0.0.0:0')
|
||||
class TestGlobalStatsResource(RestOAuth):
|
||||
|
||||
def setUp(self):
|
||||
super(TestGlobalStatsResource, self).setUp()
|
||||
self.grant_permission(self.profile, 'Stats:View')
|
||||
self.data = {'start': '2013-04-01',
|
||||
'end': '2013-04-15',
|
||||
'interval': 'day'}
|
||||
|
||||
def url(self, metric=None):
|
||||
metric = metric or STATS.keys()[0]
|
||||
return reverse('global_stats', kwargs={'metric': metric})
|
||||
class StatsAPITestMixin(object):
|
||||
|
||||
def test_cors(self, mocked):
|
||||
res = self.client.get(self.url(), data=self.data)
|
||||
|
@ -46,6 +33,22 @@ class TestGlobalStatsResource(RestOAuth):
|
|||
res = self.anon.get(self.url())
|
||||
eq_(res.status_code, 403)
|
||||
|
||||
|
||||
@mock.patch('monolith.client.Client')
|
||||
@mock.patch.object(settings, 'MONOLITH_SERVER', 'http://0.0.0.0:0')
|
||||
class TestGlobalStatsResource(RestOAuth, StatsAPITestMixin):
|
||||
|
||||
def setUp(self):
|
||||
super(TestGlobalStatsResource, self).setUp()
|
||||
self.grant_permission(self.profile, 'Stats:View')
|
||||
self.data = {'start': '2013-04-01',
|
||||
'end': '2013-04-15',
|
||||
'interval': 'day'}
|
||||
|
||||
def url(self, metric=None):
|
||||
metric = metric or STATS.keys()[0]
|
||||
return reverse('global_stats', kwargs={'metric': metric})
|
||||
|
||||
def test_bad_metric(self, mocked):
|
||||
res = self.client.get(self.url('foo'))
|
||||
eq_(res.status_code, 404)
|
||||
|
@ -114,7 +117,7 @@ class TestGlobalStatsResource(RestOAuth):
|
|||
|
||||
@mock.patch('monolith.client.Client')
|
||||
@mock.patch.object(settings, 'MONOLITH_SERVER', 'http://0.0.0.0:0')
|
||||
class TestAppStatsResource(RestOAuth):
|
||||
class TestAppStatsResource(RestOAuth, StatsAPITestMixin):
|
||||
fixtures = fixture('user_2519')
|
||||
|
||||
def setUp(self):
|
||||
|
@ -129,22 +132,6 @@ class TestAppStatsResource(RestOAuth):
|
|||
metric = metric or APP_STATS.keys()[0]
|
||||
return reverse('app_stats', kwargs={'pk': pk, 'metric': metric})
|
||||
|
||||
def test_cors(self, mocked):
|
||||
res = self.client.get(self.url(), data=self.data)
|
||||
self.assertCORS(res, 'get')
|
||||
|
||||
def test_verbs(self, mocked):
|
||||
self._allowed_verbs(self.url(), ['get'])
|
||||
|
||||
def test_monolith_down(self, mocked):
|
||||
mocked.side_effect = requests.ConnectionError
|
||||
res = self.client.get(self.url(), data=self.data)
|
||||
eq_(res.status_code, 503)
|
||||
|
||||
def test_anon(self, mocked):
|
||||
res = self.anon.get(self.url())
|
||||
eq_(res.status_code, 403)
|
||||
|
||||
def test_owner(self, mocked):
|
||||
res = self.client.get(self.url(), data=self.data)
|
||||
eq_(res.status_code, 200)
|
||||
|
@ -171,6 +158,36 @@ class TestAppStatsResource(RestOAuth):
|
|||
eq_(data['detail'][f], ['This field is required.'])
|
||||
|
||||
|
||||
@mock.patch('monolith.client.Client')
|
||||
@mock.patch.object(settings, 'MONOLITH_SERVER', 'http://0.0.0.0:0')
|
||||
class TestAppStatsTotalResource(RestOAuth, StatsAPITestMixin):
|
||||
fixtures = fixture('user_2519')
|
||||
|
||||
def setUp(self):
|
||||
super(TestAppStatsTotalResource, self).setUp()
|
||||
self.app = amo.tests.app_factory(status=amo.STATUS_PUBLIC)
|
||||
self.app.addonuser_set.create(user=self.user.get_profile())
|
||||
self.data = None # For the mixin tests.
|
||||
|
||||
def url(self, pk=None, metric=None):
|
||||
pk = pk or self.app.pk
|
||||
return reverse('app_stats_total', kwargs={'pk': pk})
|
||||
|
||||
def test_owner(self, mocked):
|
||||
res = self.client.get(self.url())
|
||||
eq_(res.status_code, 200)
|
||||
|
||||
def test_perms(self, mocked):
|
||||
self.app.addonuser_set.all().delete()
|
||||
self.grant_permission(self.profile, 'Stats:View')
|
||||
res = self.client.get(self.url())
|
||||
eq_(res.status_code, 200)
|
||||
|
||||
def test_bad_app(self, mocked):
|
||||
res = self.client.get(self.url(pk=99999999))
|
||||
eq_(res.status_code, 404)
|
||||
|
||||
|
||||
class TestTransactionResource(RestOAuth):
|
||||
fixtures = fixture('prices', 'user_2519', 'webapp_337141')
|
||||
|
||||
|
|
|
@ -14,6 +14,8 @@ series = dict((type, '%s-%s' % (type, series_re)) for type in views.SERIES)
|
|||
stats_api_patterns = patterns('',
|
||||
url(r'^stats/global/(?P<metric>[^/]+)/$', api.GlobalStats.as_view(),
|
||||
name='global_stats'),
|
||||
url(r'^stats/app/(?P<pk>[^/<>"\']+)/totals/$',
|
||||
api.AppStatsTotal.as_view(), name='app_stats_total'),
|
||||
url(r'^stats/app/(?P<pk>[^/<>"\']+)/(?P<metric>[^/]+)/$',
|
||||
api.AppStats.as_view(), name='app_stats'),
|
||||
)
|
||||
|
|
Загрузка…
Ссылка в новой задаче