0001-api-add-statistics-endpoints-48845.patch
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 |
- |