api: add statistics endpoints (#48845)

This commit is contained in:
Valentin Deniaud 2020-11-25 13:51:09 +01:00
parent 206fec2122
commit 2cc198dd70
2 changed files with 254 additions and 1 deletions

View File

@ -29,6 +29,7 @@ from django.utils.translation import ugettext as _
from django.utils.text import slugify
from django.utils.encoding import force_text
from django.utils.dateparse import parse_datetime
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.vary import vary_on_headers
from django.views.decorators.cache import cache_control
from django.shortcuts import get_object_or_404
@ -40,7 +41,7 @@ from requests.exceptions import RequestException
from rest_framework import serializers, pagination
from rest_framework.validators import UniqueTogetherValidator
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ModelViewSet, ViewSet
from rest_framework.routers import SimpleRouter
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
@ -64,6 +65,7 @@ from . import (utils, decorators, attribute_kinds, app_settings, hooks,
api_mixins)
from .models import Attribute, PasswordReset, Service
from .a2_rbac.utils import get_default_ou
from .journal_event_types import UserLogin, UserRegistration
# Retro-compatibility with older Django versions
@ -1097,3 +1099,123 @@ class AddressAutocompleteAPI(APIView):
address_autocomplete = AddressAutocompleteAPI.as_view()
class ServiceOUField(serializers.ListField):
def to_internal_value(self, data_list):
data = data_list[0].split(' ')
if not len(data) == 2:
raise ValidationError('This field should be a service slug and an OU slug separated by space.')
return super().to_internal_value(data)
class StatisticsSerializer(serializers.Serializer):
time_interval = serializers.ChoiceField(choices=['timestamp', 'day', 'month', 'year'])
service = ServiceOUField(child=serializers.SlugField(max_length=256), required=False)
ou = serializers.SlugField(required=False, allow_blank=False, max_length=256)
start = serializers.DateTimeField(required=False)
end = serializers.DateTimeField(required=False)
def validate(self, data):
if data.get('service') and data.get('ou'):
raise ValidationError('Organizational Unit and Service must not be given at the same time.')
return data
def stat(**kwargs):
'''Extend action decorator to allow passing statistics related info.'''
filters = kwargs.pop('filters', [])
decorator = action(**kwargs)
def wraps(func):
func.filters = filters
return decorator(func)
return wraps
class StatisticsAPI(ViewSet):
permission_classes = (permissions.IsAuthenticated,)
def list(self, request):
statistics = []
ous = [{'id': ou.slug, 'label': ou.name} for ou in get_ou_model().objects.all()]
services = [
{'id': '%s %s' % (service['slug'], service['ou__slug']), 'label': service['name']}
for service in Service.objects.values('slug', 'name', 'ou__slug')
]
for action in self.get_extra_actions():
url = self.reverse_action(action.url_name)
data = {
'name': action.kwargs['name'],
'url': url,
'id': action.url_name,
'filters': [],
}
if 'ou' in action.filters:
data['filters'].append({'id': 'ou', 'label': _('Organizational Unit'), 'options': ous})
if 'service' in action.filters:
data['filters'].append({'id': 'service', 'label': _('Service'), 'options': services})
statistics.append(data)
return Response({
'data': statistics,
'err': 0,
})
def get_statistics(self, request, klass, method):
serializer = StatisticsSerializer(data=request.query_params)
if not serializer.is_valid():
response = {
'data': [],
'err': 1,
'err_desc': serializer.errors
}
return Response(response, status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
kwargs = {
'group_by_time': data['time_interval'],
'start': data.get('start'),
'end': data.get('end'),
}
allowed_filters = getattr(self, self.action).filters
service, ou = data.get('service'), data.get('ou')
if service and 'service' in allowed_filters:
service_slug, ou_slug = service
kwargs['service'] = get_object_or_404(Service, slug=service_slug, ou__slug=ou_slug)
elif ou and 'ou' in allowed_filters:
kwargs['ou'] = get_object_or_404(get_ou_model(), slug=ou)
return Response({
'data': getattr(klass, method)(**kwargs),
'err': 0,
})
@stat(detail=False, name=_('Login count by authentication type'), filters=('ou', 'service'))
def login(self, request):
return self.get_statistics(request, UserLogin, 'get_method_statistics')
@stat(detail=False, name=_('Login count by service'))
def service_login(self, request):
return self.get_statistics(request, UserLogin, 'get_service_statistics')
@stat(detail=False, name=_('Login count by organizational unit'))
def service_ou_login(self, request):
return self.get_statistics(request, UserLogin, 'get_service_ou_statistics')
@stat(detail=False, name=_('Registration count by type'), filters=('ou', 'service'))
def registration(self, request):
return self.get_statistics(request, UserRegistration, 'get_method_statistics')
@stat(detail=False, name=_('Registration count by service'))
def service_registration(self, request):
return self.get_statistics(request, UserRegistration, 'get_service_statistics')
@stat(detail=False, name=_('Registration count by organizational unit'))
def service_ou_registration(self, request):
return self.get_statistics(request, UserRegistration, 'get_service_ou_statistics')
router.register(r'statistics', StatisticsAPI, base_name='a2-api-statistics')

View File

@ -35,12 +35,14 @@ from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.http import urlencode
from rest_framework import VERSION as drf_version
from django_rbac.models import SEARCH_OP
from django_rbac.utils import get_role_model, get_ou_model
from requests.models import Response
from authentic2.a2_rbac.models import Role
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.journal.models import EventType, Event
from authentic2.models import Service, Attribute, AttributeValue, AuthorizedRole
from authentic2.utils import good_next_url
@ -2054,3 +2056,132 @@ def test_api_password_change_mark_as_deleted(app, settings, admin, ou1):
user2.mark_as_deleted()
response = app.post_json(url, params=payload)
assert User.objects.get(username='john.doe').check_password('password2')
@pytest.mark.skipif(drf_version.startswith('3.4'), reason='no support for old django rest framework')
def test_api_statistics_list(app, admin):
headers = basic_authorization_header(admin)
resp = app.get('/api/statistics/', headers=headers)
assert len(resp.json['data']) == 6
login_stats = {
'name': 'Login count by authentication type',
'url': 'http://testserver/api/statistics/login/',
'id': 'login',
'filters': [
{
'id': 'ou',
'label': 'Organizational Unit',
'options': [{'id': 'default', 'label': 'Default organizational unit'}],
},
{'id': 'service', 'label': 'Service', 'options': []},
],
}
assert login_stats in resp.json['data']
assert {
'name': 'Login count by service',
'url': 'http://testserver/api/statistics/service_login/',
'id': 'service-login',
'filters': [],
} in resp.json['data']
service = Service.objects.create(name='Service1', slug='service1', ou=get_default_ou())
service = Service.objects.create(name='Service2', slug='service2', ou=get_default_ou())
login_stats['filters'][1]['options'].append({'id': 'service1 default', 'label': 'Service1'})
login_stats['filters'][1]['options'].append({'id': 'service2 default', 'label': 'Service2'})
resp = app.get('/api/statistics/', headers=headers)
assert login_stats in resp.json['data']
@pytest.mark.skipif(drf_version.startswith('3.4'), reason='no support for old django rest framework')
@pytest.mark.parametrize(
'event_type_name,event_name', [('user.login', 'login'), ('user.registration', 'registration')]
)
def test_api_statistics(app, admin, freezer, event_type_name, event_name):
headers = basic_authorization_header(admin)
resp = app.get('/api/statistics/login/', headers=headers, status=400)
assert resp.json == {"data": [], "err": 1, "err_desc": {"time_interval": ["This field is required."]}}
resp = app.get('/api/statistics/login/?time_interval=month', headers=headers)
assert resp.json == {"data": {"series": [], "x_labels": []}, "err": 0}
user = User.objects.create(username='john.doe', email='john.doe@example.com')
portal = Service.objects.create(name='portal', slug='portal', ou=get_default_ou())
agendas = Service.objects.create(name='agendas', slug='agendas', ou=get_default_ou())
method = {'how': 'password-on-https'}
method2 = {'how': 'fc'}
event_type = EventType.objects.get_for_name(event_type_name)
event_type_definition = event_type.definition
freezer.move_to('2020-02-03 12:00')
event = Event.objects.create(type=event_type, references=[portal], data=method)
event = Event.objects.create(type=event_type, references=[agendas], data=method)
freezer.move_to('2020-03-04 13:00')
event = Event.objects.create(type=event_type, references=[agendas], data=method)
event = Event.objects.create(type=event_type, references=[portal], data=method2)
resp = app.get('/api/statistics/%s/?time_interval=month' % event_name, headers=headers)
data = resp.json['data']
data['series'].sort(key=lambda x: x['label'])
assert data == {
'x_labels': ['2020-02', '2020-03'],
'series': [{'label': 'FranceConnect', 'data': [None, 1]}, {'label': 'password', 'data': [2, 1]}],
}
resp = app.get('/api/statistics/%s/?time_interval=month&ou=default' % event_name, headers=headers)
data = resp.json['data']
data['series'].sort(key=lambda x: x['label'])
assert data == {
'x_labels': ['2020-02', '2020-03'],
'series': [{'label': 'FranceConnect', 'data': [None, 1]}, {'label': 'password', 'data': [2, 1]}],
}
resp = app.get(
'/api/statistics/%s/?time_interval=month&service=agendas default' % event_name, headers=headers
)
data = resp.json['data']
assert data == {'x_labels': ['2020-02', '2020-03'], 'series': [{'label': 'password', 'data': [1, 1]}]}
resp = app.get(
'/api/statistics/%s/?time_interval=month&start=2020-03-01T01:01' % event_name, headers=headers
)
data = resp.json['data']
data['series'].sort(key=lambda x: x['label'])
assert data == {
'x_labels': ['2020-03'],
'series': [{'label': 'FranceConnect', 'data': [1]}, {'label': 'password', 'data': [1]}],
}
resp = app.get(
'/api/statistics/%s/?time_interval=month&end=2020-03-01T01:01' % event_name, headers=headers
)
data = resp.json['data']
assert data == {'x_labels': ['2020-02'], 'series': [{'label': 'password', 'data': [2]}]}
resp = app.get(
'/api/statistics/%s/?time_interval=year&service=portal default' % event_name, headers=headers
)
data = resp.json['data']
data['series'].sort(key=lambda x: x['label'])
assert data == {
'x_labels': ['2020'],
'series': [{'label': 'FranceConnect', 'data': [1]}, {'label': 'password', 'data': [1]}],
}
resp = app.get('/api/statistics/service_%s/?time_interval=month' % event_name, headers=headers)
data = resp.json['data']
data['series'].sort(key=lambda x: x['label'])
assert data == {
'x_labels': ['2020-02', '2020-03'],
'series': [{'label': 'agendas', 'data': [1, 1]}, {'label': 'portal', 'data': [1, 1]}],
}
resp = app.get('/api/statistics/service_ou_%s/?time_interval=month' % event_name, headers=headers)
data = resp.json['data']
assert data == {
'x_labels': ['2020-02', '2020-03'],
'series': [{'label': 'Default organizational unit', 'data': [2, 2]}],
}