Projet

Général

Profil

0001-api-add-statistics-endpoints-48845.patch

Valentin Deniaud, 25 novembre 2020 17:58

Télécharger (13,1 ko)

Voir les différences:

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(-)
src/authentic2/api_views.py
29 29
from django.utils.text import slugify
30 30
from django.utils.encoding import force_text
31 31
from django.utils.dateparse import parse_datetime
32
from django.utils.translation import ugettext_lazy as _
32 33
from django.views.decorators.vary import vary_on_headers
33 34
from django.views.decorators.cache import cache_control
34 35
from django.shortcuts import get_object_or_404
......
40 41
from rest_framework import serializers, pagination
41 42
from rest_framework.validators import UniqueTogetherValidator
42 43
from rest_framework.views import APIView
43
from rest_framework.viewsets import ModelViewSet
44
from rest_framework.viewsets import ModelViewSet, ViewSet
44 45
from rest_framework.routers import SimpleRouter
45 46
from rest_framework.generics import GenericAPIView
46 47
from rest_framework.response import Response
......
64 65
               api_mixins)
65 66
from .models import Attribute, PasswordReset, Service
66 67
from .a2_rbac.utils import get_default_ou
68
from .journal_event_types import UserLogin, UserRegistration
67 69

  
68 70

  
69 71
# Retro-compatibility with older Django versions
......
1065 1067

  
1066 1068

  
1067 1069
address_autocomplete = AddressAutocompleteAPI.as_view()
1070

  
1071

  
1072
class ServiceOUField(serializers.ListField):
1073
    def to_internal_value(self, data_list):
1074
        data = data_list[0].split('|')
1075
        if not len(data) == 2:
1076
            raise ValidationError('This field should be a service slug and an OU slug separated by |.')
1077
        return super().to_internal_value(data)
1078

  
1079

  
1080
class StatisticsSerializer(serializers.Serializer):
1081
    time_interval = serializers.ChoiceField(choices=['timestamp', 'day', 'month', 'year'])
1082
    service = ServiceOUField(child=serializers.SlugField(), required=False)
1083
    ou = serializers.SlugField(required=False)
1084
    start = serializers.DateTimeField(required=False)
1085
    end = serializers.DateTimeField(required=False)
1086

  
1087
    def validate(self, data):
1088
        if data.get('service') and data.get('ou'):
1089
            raise ValidationError('Organizational Unit and Service must not be given at the same time.')
1090
        return data
1091

  
1092

  
1093
def stat(**kwargs):
1094
    '''Extend action decorator to allow passing statistics related info.'''
1095
    filters = kwargs.pop('filters', [])
1096
    decorator = action(**kwargs)
1097

  
1098
    def wraps(func):
1099
        func.filters = filters
1100
        return decorator(func)
1101
    return wraps
1102

  
1103

  
1104
class StatisticsAPI(ViewSet):
1105
    permission_classes = (permissions.IsAuthenticated,)
1106

  
1107
    def list(self, request):
1108
        statistics = []
1109
        ous = [{'id': ou.slug, 'label': ou.name} for ou in get_ou_model().objects.all()]
1110
        services = [
1111
            {'id': '%s|%s' % (service['slug'], service['ou__slug']), 'label': service['name']}
1112
            for service in Service.objects.values('slug', 'name', 'ou__slug')
1113
        ]
1114

  
1115
        for action in self.get_extra_actions():
1116
            url = self.reverse_action(action.url_name)
1117
            data = {
1118
                'name': action.kwargs['name'],
1119
                'url': url,
1120
                'id': action.url_name,
1121
                'filters': [],
1122
            }
1123
            if 'ou' in action.filters:
1124
                data['filters'].append({'id': 'ou', 'label': _('Organizational Unit'), 'options': ous})
1125
            if 'service' in action.filters:
1126
                data['filters'].append({'id': 'service', 'label': _('Service'), 'options': services})
1127
            statistics.append(data)
1128

  
1129
        return Response({
1130
            'data': statistics,
1131
            'err': 0,
1132
        })
1133

  
1134
    def get_statistics(self, request, klass, method):
1135
        serializer = StatisticsSerializer(data=request.query_params)
1136
        if not serializer.is_valid():
1137
            response = {
1138
                'data': [],
1139
                'err': 1,
1140
                'err_desc': serializer.errors
1141
            }
1142
            return Response(response, status.HTTP_400_BAD_REQUEST)
1143
        data = serializer.validated_data
1144

  
1145
        kwargs = {
1146
            'group_by_time': data['time_interval'],
1147
            'start': data.get('start'),
1148
            'end': data.get('end'),
1149
        }
1150

  
1151
        allowed_filters = getattr(self, self.action).filters
1152
        service, ou = data.get('service'), data.get('ou')
1153
        if service and 'service' in allowed_filters:
1154
            service_slug, ou_slug = service
1155
            kwargs['service'] = get_object_or_404(Service, slug=service_slug, ou__slug=ou_slug)
1156
        elif ou and 'ou' in allowed_filters:
1157
            kwargs['ou'] = get_object_or_404(get_ou_model(), slug=ou)
1158

  
1159
        return Response({
1160
            'data': getattr(klass, method)(**kwargs),
1161
            'err': 0,
1162
        })
1163

  
1164
    @stat(detail=False, name=_('Login count by authentication type'), filters=('ou', 'service'))
1165
    def login(self, request):
1166
        return self.get_statistics(request, UserLogin, 'get_method_statistics')
1167

  
1168
    @stat(detail=False, name=_('Login count by service'))
1169
    def service_login(self, request):
1170
        return self.get_statistics(request, UserLogin, 'get_service_statistics')
1171

  
1172
    @stat(detail=False, name=_('Login count by organizational unit'))
1173
    def service_ou_login(self, request):
1174
        return self.get_statistics(request, UserLogin, 'get_service_ou_statistics')
1175

  
1176
    @stat(detail=False, name=_('Registration count by type'), filters=('ou', 'service'))
1177
    def registration(self, request):
1178
        return self.get_statistics(request, UserRegistration, 'get_method_statistics')
1179

  
1180
    @stat(detail=False, name=_('Registration count by service'))
1181
    def service_registration(self, request):
1182
        return self.get_statistics(request, UserRegistration, 'get_service_statistics')
1183

  
1184
    @stat(detail=False, name=_('Registration count by organizational unit'))
1185
    def service_ou_registration(self, request):
1186
        return self.get_statistics(request, UserRegistration, 'get_service_ou_statistics')
1187

  
1188

  
1189
router.register(r'statistics', StatisticsAPI, base_name='a2-api-statistics')
tests/test_api.py
35 35
from django.utils.timezone import now
36 36
from django.utils.http import urlencode
37 37

  
38
from rest_framework import VERSION as drf_version
38 39
from django_rbac.models import SEARCH_OP
39 40
from django_rbac.utils import get_role_model, get_ou_model
40 41
from requests.models import Response
41 42

  
42 43
from authentic2.a2_rbac.models import Role
43 44
from authentic2.a2_rbac.utils import get_default_ou
45
from authentic2.apps.journal.models import EventType, Event
44 46
from authentic2.models import Service, Attribute, AttributeValue, AuthorizedRole
45 47
from authentic2.utils import good_next_url
46 48

  
......
1962 1964

  
1963 1965
    payload['phone'] = '1#2'
1964 1966
    app.post_json('/api/users/', headers=headers, params=payload, status=400)
1967

  
1968

  
1969
@pytest.mark.skipif(drf_version.startswith('3.4'), reason='no support for old django rest framework')
1970
def test_api_statistics_list(app, admin):
1971
    headers = basic_authorization_header(admin)
1972
    resp = app.get('/api/statistics/', headers=headers)
1973
    assert len(resp.json['data']) == 6
1974
    login_stats = {
1975
        'name': 'Login count by authentication type',
1976
        'url': 'http://testserver/api/statistics/login/',
1977
        'id': 'login',
1978
        'filters': [
1979
            {
1980
                'id': 'ou',
1981
                'label': 'Organizational Unit',
1982
                'options': [{'id': 'default', 'label': 'Default organizational unit'}],
1983
            },
1984
            {'id': 'service', 'label': 'Service', 'options': []},
1985
        ],
1986
    }
1987
    assert login_stats in resp.json['data']
1988
    assert {
1989
        'name': 'Login count by service',
1990
        'url': 'http://testserver/api/statistics/service_login/',
1991
        'id': 'service-login',
1992
        'filters': [],
1993
    } in resp.json['data']
1994

  
1995
    service = Service.objects.create(name='Service1', slug='service1', ou=get_default_ou())
1996
    service = Service.objects.create(name='Service2', slug='service2', ou=get_default_ou())
1997
    login_stats['filters'][1]['options'].append({'id': 'service1|default', 'label': 'Service1'})
1998
    login_stats['filters'][1]['options'].append({'id': 'service2|default', 'label': 'Service2'})
1999

  
2000
    resp = app.get('/api/statistics/', headers=headers)
2001
    assert login_stats in resp.json['data']
2002

  
2003

  
2004
@pytest.mark.skipif(drf_version.startswith('3.4'), reason='no support for old django rest framework')
2005
@pytest.mark.parametrize(
2006
    'event_type_name,event_name', [('user.login', 'login'), ('user.registration', 'registration')]
2007
)
2008
def test_api_statistics(app, admin, freezer, event_type_name, event_name):
2009
    headers = basic_authorization_header(admin)
2010
    resp = app.get('/api/statistics/login/', headers=headers, status=400)
2011
    assert resp.json == {"data": [], "err": 1, "err_desc": {"time_interval": ["This field is required."]}}
2012

  
2013
    resp = app.get('/api/statistics/login/?time_interval=month', headers=headers)
2014
    assert resp.json == {"data": {"series": [], "x_labels": []}, "err": 0}
2015

  
2016
    user = User.objects.create(username='john.doe', email='john.doe@example.com')
2017
    portal = Service.objects.create(name='portal', slug='portal', ou=get_default_ou())
2018
    agendas = Service.objects.create(name='agendas', slug='agendas', ou=get_default_ou())
2019

  
2020
    method = {'how': 'password-on-https'}
2021
    method2 = {'how': 'fc'}
2022

  
2023
    event_type = EventType.objects.get_for_name(event_type_name)
2024
    event_type_definition = event_type.definition
2025

  
2026
    freezer.move_to('2020-02-03 12:00')
2027
    event = Event.objects.create(type=event_type, references=[portal], data=method)
2028
    event = Event.objects.create(type=event_type, references=[agendas], data=method)
2029

  
2030
    freezer.move_to('2020-03-04 13:00')
2031
    event = Event.objects.create(type=event_type, references=[agendas], data=method)
2032
    event = Event.objects.create(type=event_type, references=[portal], data=method2)
2033

  
2034
    resp = app.get('/api/statistics/%s/?time_interval=month' % event_name, headers=headers)
2035
    data = resp.json['data']
2036
    data['series'].sort(key=lambda x: x['label'])
2037
    assert data == {
2038
        'x_labels': ['2020-02', '2020-03'],
2039
        'series': [{'label': 'FranceConnect', 'data': [None, 1]}, {'label': 'password', 'data': [2, 1]}],
2040
    }
2041

  
2042
    resp = app.get('/api/statistics/%s/?time_interval=month&ou=default' % event_name, headers=headers)
2043
    data = resp.json['data']
2044
    data['series'].sort(key=lambda x: x['label'])
2045
    assert data == {
2046
        'x_labels': ['2020-02', '2020-03'],
2047
        'series': [{'label': 'FranceConnect', 'data': [None, 1]}, {'label': 'password', 'data': [2, 1]}],
2048
    }
2049

  
2050
    resp = app.get(
2051
        '/api/statistics/%s/?time_interval=month&service=agendas|default' % event_name, headers=headers
2052
    )
2053
    data = resp.json['data']
2054
    assert data == {'x_labels': ['2020-02', '2020-03'], 'series': [{'label': 'password', 'data': [1, 1]}]}
2055

  
2056
    resp = app.get(
2057
        '/api/statistics/%s/?time_interval=month&start=2020-03-01T01:01' % event_name, headers=headers
2058
    )
2059
    data = resp.json['data']
2060
    assert data == {
2061
        'x_labels': ['2020-03'],
2062
        'series': [{'label': 'FranceConnect', 'data': [1]}, {'label': 'password', 'data': [1]}],
2063
    }
2064

  
2065
    resp = app.get(
2066
        '/api/statistics/%s/?time_interval=month&end=2020-03-01T01:01' % event_name, headers=headers
2067
    )
2068
    data = resp.json['data']
2069
    assert data == {'x_labels': ['2020-02'], 'series': [{'label': 'password', 'data': [2]}]}
2070

  
2071
    resp = app.get(
2072
        '/api/statistics/%s/?time_interval=year&service=portal|default' % event_name, headers=headers
2073
    )
2074
    data = resp.json['data']
2075
    data['series'].sort(key=lambda x: x['label'])
2076
    assert data == {
2077
        'x_labels': ['2020'],
2078
        'series': [{'label': 'FranceConnect', 'data': [1]}, {'label': 'password', 'data': [1]}],
2079
    }
2080

  
2081
    resp = app.get('/api/statistics/service_%s/?time_interval=month' % event_name, headers=headers)
2082
    data = resp.json['data']
2083
    data['series'].sort(key=lambda x: x['label'])
2084
    assert data == {
2085
        'x_labels': ['2020-02', '2020-03'],
2086
        'series': [{'label': 'agendas', 'data': [1, 1]}, {'label': 'portal', 'data': [1, 1]}],
2087
    }
2088

  
2089
    resp = app.get('/api/statistics/service_ou_%s/?time_interval=month' % event_name, headers=headers)
2090
    data = resp.json['data']
2091
    assert data == {
2092
        'x_labels': ['2020-02', '2020-03'],
2093
        'series': [{'label': 'Default organizational unit', 'data': [2, 2]}],
2094
    }
1965
-