From b9b034afc6b8968d73e6bb7cad4e305022cb7a40 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 25 Nov 2020 13:51:09 +0100 Subject: [PATCH] api: add statistics endpoints (#48845) --- src/authentic2/api_views.py | 124 +++++++++++++++++++++++++++++++++- tests/test_api.py | 130 ++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 1 deletion(-) diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py index 1369f8ed..0d8a34e3 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -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 @@ -1065,3 +1067,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 |.') + return super().to_internal_value(data) + + +class StatisticsSerializer(serializers.Serializer): + time_interval = serializers.ChoiceField(choices=['timestamp', 'day', 'month', 'year']) + service = ServiceOUField(child=serializers.SlugField(), required=False) + ou = serializers.SlugField(required=False) + 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') diff --git a/tests/test_api.py b/tests/test_api.py index 7f8d016c..fc08a898 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 @@ -1962,3 +1964,131 @@ def test_phone_normalization_nok(settings, app, admin): payload['phone'] = '1#2' app.post_json('/api/users/', headers=headers, params=payload, status=400) + + +@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'] + 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]}], + } -- 2.20.1