Skip to content

Commit

Permalink
Add generic stats endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
daanvdk committed Nov 6, 2023
1 parent aeab374 commit 5e6cda5
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 1 deletion.
8 changes: 8 additions & 0 deletions binder/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ def urls(self):
urls.append(re_path(r'^{}/(?P<pk>[0-9]+)/{}/$'.format(route.route, ff),
view.as_view(), {'file_field': ff, 'router': self}, name='{}.{}'.format(name, ff)))

# Stats endpoint
urls.append(re_path(
r'^{}/stats/$'.format(route.route),
view.as_view(),
{'method': 'stats', 'router': self},
name='{}.stats'.format(name),
))

# Custom endpoints
for m in dir(view):
method = getattr(view, m)
Expand Down
132 changes: 131 additions & 1 deletion binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from django.http.request import RawPostDataException
from django.http.multipartparser import MultiPartParser
from django.db import models, connections
from django.db.models import Q, F
from django.db.models import Q, F, Count, Sum, Min, Max
from django.db.models.lookups import Transform
from django.utils import timezone
from django.db import transaction
Expand All @@ -35,6 +35,14 @@
from .json import JsonResponse, jsonloads


STAT_AGGREGATES = {
'count': Count,
'sum': Sum,
'min': Min,
'max': Max,
}


def get_joins_from_queryset(queryset):
"""
Given a queryset returns a set of lines that are used to determine which
Expand Down Expand Up @@ -2834,6 +2842,128 @@ def view_history(self, request, pk=None, **kwargs):
return history.view_changesets(request, changesets.order_by('-id'))


def stats(self, request):
try:
stats = self._parse_stats(request.GET['stats'])
except KeyError:
raise BinderRequestError('no stats parameter provided')

include_annotations = self._parse_include_annotations(request)
queryset, annotations = self._get_filtered_queryset_base(request, None, include_annotations)

return JsonResponse({
key: self._get_stat(request, queryset, annotations, include_annotations, **stat)
for key, stat in stats.items()
})


def _parse_stats(self, stats):
stats = jsonloads(stats)

if not isinstance(stats, dict):
raise BinderRequestError('stats should be a dictionary')

errors = []
for stat, params in stats.items():
if not isinstance(params, dict):
errors.append(f'stats.{stat} should be a dictionary')
continue

for key, value in params.items():
if key == 'field':
params['field'] = self._check_field(stat, key, value, errors)

elif key == 'aggregate':
try:
params['aggregate'] = STAT_AGGREGATES[value]
except (ValueError, KeyError):
errors.append(f'stats.{stat}.aggregate is not a valid aggregate')

elif key == 'group_by':
params['group_by'] = self._check_field(stat, key, value, errors)

elif key == 'filters':
if not isinstance(params, dict):
errors.append(f'stats.{stat}.filters should be a dictionary')

else:
errors.append(f'stats.{stat}.{key} is not a valid key')

if errors:
raise BinderRequestError('\n'.join(errors))

return stats

def _check_field(self, stat, key, value, errors):
if not isinstance(value, str):
errors.append(f'stats.{stat}.{key} should be a string')
return

model = self.model
parts = []
while True:
try:
head, value = value.split('.', 1)
except ValueError:
break

try:
field = model._meta.get_field(head)
assert field.is_relation
except (FieldDoesNotExist, AssertionError):
errors.append(f'stats.{stat}.{key} references relation {model.__name__}.{head} that does not exist')
return

parts.append(head)
if isinstance(field, django.db.models.fields.reverse_related.ForeignObjectRel):
model = field.related_model
else:
model = field.remote_field.model

try:
model._meta.get_field(value)
except FieldDoesNotExist:
errors.append(f'stats.{stat}.{key} references field {model.__name__}.{value} that does not exist')
return

parts.append(value)
return '__'.join(parts)


def _get_stat(self, request, queryset, annotations, include_annotations, field='id', aggregate=Count, group_by=None, filters={}):
for key, value in filters.items():
q, distinct = self._parse_filter(key, value, request, include_annotations)
queryset = self._apply_q_with_possible_annotations(queryset, q, annotations)
if distinct:
queryset = queryset.distinct()
queryset = self._apply_annotations(queryset, annotations, field, group_by)

if group_by is None:
return queryset.aggregate(result=aggregate(field))['result']
else:
return dict(
queryset
.order_by()
.values(group_by)
.annotate(_binder_stats_aggregate=aggregate(field))
.values_list(group_by, '_binder_stats_aggregate')
)


def _apply_annotations(self, queryset, annotations, *fields):
for field in fields:
if field is None:
continue
field = field.split('__', 1)[0]
try:
annotation = annotations.pop(field)
except KeyError:
pass
else:
queryset = queryset.annotate(**{field: annotation})
return queryset



def api_catchall(request):
try:
Expand Down
75 changes: 75 additions & 0 deletions tests/test_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import json

from django.test import TestCase
from django.contrib.auth.models import User

from .testapp.models import Animal, Caretaker, Zoo


class StatsTest(TestCase):

def setUp(self):
zoo_1 = Zoo.objects.create(name='Zoo 1')
zoo_2 = Zoo.objects.create(name='Zoo 2')

caretaker = Caretaker.objects.create(name='Caretaker')

Animal.objects.create(name='Animal 1', zoo=zoo_1, caretaker=caretaker)
Animal.objects.create(name='Animal 2', zoo=zoo_2, caretaker=caretaker)
Animal.objects.create(name='Animal 3', zoo=zoo_2, caretaker=None)

u = User(username='testuser', is_active=True, is_superuser=True)
u.set_password('test')
u.save()

self.assertTrue(self.client.login(username='testuser', password='test'))

def get_stats(self, params={}, **stats):
res = self.client.get('/animal/stats/', {
'stats': json.dumps(stats),
**params,
})
if res.status_code != 200:
print(res.content.decode())
self.assertEqual(res.status_code, 200)
return json.loads(res.content)

def test_animals_without_caretaker(self):
res = self.get_stats(
animals_without_caretaker={
'filters': {'caretaker:isnull': 'true'},
},
)
self.assertEqual(res, {
'animals_without_caretaker': 1,
})

def test_animals_by_zoo(self):
res = self.get_stats(
animals_by_zoo={
'group_by': 'zoo.name',
},
)
self.assertEqual(res, {
'animals_by_zoo': {
'Zoo 1': 1,
'Zoo 2': 2,
},
})

def test_stats_filtered(self):
res = self.get_stats(
total={},
animals_without_caretaker={
'filters': {'caretaker:isnull': 'true'},
},
animals_by_zoo={
'group_by': 'zoo.name',
},
params={'.zoo.name': 'Zoo 1'},
)
self.assertEqual(res, {
'total': 1,
'animals_without_caretaker': 0,
'animals_by_zoo': {'Zoo 1': 1},
})

0 comments on commit 5e6cda5

Please sign in to comment.