Projet

Général

Profil

0001-journal-add-event-type-statistics-47467.patch

Valentin Deniaud, 23 novembre 2020 18:18

Télécharger (18,8 ko)

Voir les différences:

Subject: [PATCH] journal: add event type statistics (#47467)

 src/authentic2/apps/journal/models.py |  33 ++++++-
 src/authentic2/apps/journal/utils.py  |  19 ++++
 src/authentic2/journal_event_types.py | 102 +++++++++++++++++----
 tests/test_journal.py                 | 124 ++++++++++++++++++++++++--
 4 files changed, 250 insertions(+), 28 deletions(-)
src/authentic2/apps/journal/models.py
23 23
from django.conf import settings
24 24
from django.contrib.auth import get_user_model
25 25
from django.contrib.postgres.fields import ArrayField, JSONField
26
from django.contrib.postgres.fields.jsonb import KeyTextTransform
26 27
from django.contrib.contenttypes.models import ContentType
27 28
from django.core.exceptions import ObjectDoesNotExist
28 29
from django.db import models
29
from django.db.models import QuerySet, Q, F, Value
30
from django.db.models import QuerySet, Q, F, Value, Count
31
from django.db.models.functions import Trunc
30 32
from django.utils.translation import ugettext_lazy as _
31 33
from django.utils.timezone import utc, now
32 34

  
......
108 110
    def get_message(self, event, context=None):
109 111
        return self.label
110 112

  
113
    @classmethod
114
    def get_statistics(cls, group_by_time, group_by_field, group_by_references=False, start=None, end=None):
115
        if group_by_time not in ('timestamp', 'day', 'week', 'month', 'year'):
116
            raise ValueError('Usupported value for group_by_time: %s' % time_group_by)
117

  
118
        event_type = EventType.objects.get_for_name(cls.name)
119
        qs = Event.objects.filter(type=event_type)
120

  
121
        if start:
122
            qs = qs.filter(timestamp__gte=start)
123
        if end:
124
            qs = qs.filter(timestamp__lte=end)
125

  
126
        value_fields = []
127
        if group_by_time != 'timestamp':
128
            qs = qs.annotate(**{group_by_time: Trunc('timestamp', kind=group_by_time)})
129

  
130
        if not group_by_field.startswith('user') and not group_by_field.startswith('session'):
131
            # get field from JSONField
132
            qs = qs.annotate(**{group_by_field: KeyTextTransform(group_by_field, 'data')})
133

  
134
        values = [group_by_time, group_by_field]
135
        if group_by_references:
136
            values.append('reference_ids')
137
        qs = qs.values(*values)
138

  
139
        qs = qs.annotate(count=Count('id'))
140
        return qs.order_by(group_by_time)
141

  
111 142
    def __repr__(self):
112 143
        return '<EventTypeDefinition %r %s>' % (self.name, self.label)
113 144

  
src/authentic2/apps/journal/utils.py
30 30
            old[key] = _json_value(old_value)
31 31
        new[key] = _json_value(form.cleaned_data.get(key))
32 32
    return {'old': old, 'new': new}
33

  
34

  
35
class Statistics:
36
    def __init__(self, qs, x_label_field):
37
        self.x_labels = list(qs.distinct().values_list(x_label_field, flat=True))
38
        self._x_labels_indexes = {label: i for i, label in enumerate(self.x_labels)}
39
        self.series = {}
40

  
41
    def add(self, x_label, y_label, value, **kwargs):
42
        serie = self.get_serie(y_label, **kwargs)
43
        index = self.x_index(x_label)
44
        serie[index] = (serie[index] or 0) + value
45

  
46
    def get_serie(self, label, loop_label=None):
47
        series = self.series if not loop_label else self.series.setdefault(loop_label, {})
48
        return series.setdefault(label, [None] * len(self.x_labels))
49

  
50
    def x_index(self, x_label):
51
        return self._x_labels_indexes[x_label]
src/authentic2/journal_event_types.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
from django.contrib.contenttypes.models import ContentType
17 18
from django.utils.translation import ugettext_lazy as _
18 19

  
19 20
from authentic2.custom_user.models import get_attributes_map
20
from authentic2.apps.journal.models import EventTypeDefinition
21
from authentic2.apps.journal.utils import form_to_old_new
21
from authentic2.apps.journal.models import EventTypeDefinition, n_2_pairing_rev
22
from authentic2.apps.journal.utils import form_to_old_new, Statistics
22 23
from authentic2.custom_user.models import User
23 24

  
24
from . import models
25
from .models import Service
25 26

  
26 27

  
27 28
class EventTypeWithService(EventTypeDefinition):
......
38 39

  
39 40
    @classmethod
40 41
    def get_service_name(self, event):
41
        (service,) = event.get_typed_references(models.Service)
42
        (service,) = event.get_typed_references(Service)
42 43
        if service is not None:
43 44
            return str(service)
44 45
        if 'service_name' in event.data:
......
46 47
        return ''
47 48

  
48 49

  
50
class EventTypeWithMethod(EventTypeWithService):
51
    @classmethod
52
    def record(cls, user, session, service, how):
53
        super().record(user=user, session=session, service=service, data={'how': how})
54

  
55
    @classmethod
56
    def get_method_statistics(cls, group_by_time='timestamp', start=None, end=None):
57
        qs = cls.get_statistics(group_by_time=group_by_time, group_by_field='how', start=start, end=end)
58
        stats = Statistics(qs, x_label_field=group_by_time)
59

  
60
        for stat in qs:
61
            stats.add(x_label=stat[group_by_time], y_label=stat['how'], value=stat['count'])
62

  
63
        series = []
64
        for how, data in stats.series.items():
65
            label = _(login_method_label(how or ''))
66
            series.append({'label': label, 'data': data})
67

  
68
        return {
69
            'x_labels': [label.isoformat() for label in stats.x_labels],
70
            'series': series,
71
        }
72

  
73
    @classmethod
74
    def _get_method_statistics_by_reference(cls, group_by_time, reference, **kwargs):
75
        qs = cls.get_statistics(group_by_time, 'how', group_by_references=True, **kwargs)
76
        stats = Statistics(qs, x_label_field=group_by_time)
77

  
78
        def get_reference_label(instance_pk, labels_cache={}):
79
            label = labels_cache.get(instance_pk)
80
            if not label:
81
                service = Service.objects.get(pk=instance_pk)
82
                if reference == 'service':
83
                    label = str(service)
84
                elif reference == 'ou':
85
                    label = str(service.ou)
86
            return label
87

  
88
        service_ct_id = ContentType.objects.get_for_model(Service).pk
89
        for stat in qs:
90
            for reference_id in stat['reference_ids'] or []:
91
                content_type_id, instance_pk = n_2_pairing_rev(reference_id)
92
                if content_type_id == service_ct_id:
93
                    reference_label = get_reference_label(instance_pk)
94
                    break
95
            else:
96
                reference_label = _('None')
97
            stats.add(
98
                x_label=stat[group_by_time],
99
                y_label=stat['how'],
100
                value=stat['count'],
101
                loop_label=reference_label,
102
            )
103

  
104
        series = {}
105
        for loop_label, y_data in stats.series.items():
106
            for how, data in y_data.items():
107
                y_label = _(login_method_label(how or ''))
108
                service_series = series.setdefault(loop_label, [])
109
                service_series.append({'label': y_label, 'data': data})
110

  
111
        return {
112
            'x_labels': [label.isoformat() for label in stats.x_labels],
113
            'series': series,
114
        }
115

  
116
    @classmethod
117
    def get_service_statistics(cls, group_by_time='timestamp', start=None, end=None, **kwargs):
118
        return cls._get_method_statistics_by_reference(group_by_time, 'service', start=start, end=end)
119

  
120
    @classmethod
121
    def get_service_ou_statistics(cls, group_by_time='timestamp', start=None, end=None, **kwargs):
122
        return cls._get_method_statistics_by_reference(group_by_time, 'ou', start=start, end=end)
123

  
124

  
49 125
def login_method_label(how):
50 126
    if how.startswith('password'):
51 127
        return _('password')
......
73 149
                yield name
74 150

  
75 151

  
76
class UserLogin(EventTypeWithService):
152
class UserLogin(EventTypeWithMethod):
77 153
    name = 'user.login'
78 154
    label = _('login')
79 155

  
80
    @classmethod
81
    def record(cls, user, session, service, how):
82
        super().record(user=user, session=session, service=service, data={'how': how})
83

  
84 156
    @classmethod
85 157
    def get_message(cls, event, context):
86 158
        how = event.get_data('how')
......
115 187
        return _('registration request with email "%s"') % email
116 188

  
117 189

  
118
class UserRegistration(EventTypeWithService):
190
class UserRegistration(EventTypeWithMethod):
119 191
    name = 'user.registration'
120 192
    label = _('registration')
121 193

  
122
    @classmethod
123
    def record(cls, user, session, service, how):
124
        super().record(user=user, session=session, service=service, data={'how': how})
125

  
126 194
    @classmethod
127 195
    def get_message(cls, event, context):
128 196
        how = event.get_data('how')
......
219 287
        super().record(user=user, session=session, service=service)
220 288

  
221 289

  
222
class UserServiceSSO(EventTypeWithService):
290
class UserServiceSSO(EventTypeWithMethod):
223 291
    name = 'user.service.sso'
224 292
    label = _('service single sign on')
225 293

  
226
    @classmethod
227
    def record(cls, user, session, service, how):
228
        super().record(user=user, session=session, service=service, data={'how': how})
229

  
230 294
    @classmethod
231 295
    def get_message(cls, event, context):
232 296
        service_name = cls.get_service_name(event)
tests/test_journal.py
19 19

  
20 20
import mock
21 21
import pytest
22
import pytz
22 23

  
23 24
from django.contrib.auth import get_user_model
24 25
from django.core.management import call_command
......
97 98
    assert Event.objects.which_references(service).count() == 6
98 99
    assert Event.objects.which_references(Service).count() == 11
99 100
    assert Event.objects.from_cursor(ev1.cursor).count() == 12
100
    assert list(Event.objects.all()[ev2.cursor:2]) == events[6:8]
101
    assert list(Event.objects.all()[-4:ev2.cursor]) == events[3:7]
101
    assert list(Event.objects.all()[ev2.cursor : 2]) == events[6:8]
102
    assert list(Event.objects.all()[-4 : ev2.cursor]) == events[3:7]
102 103
    assert set(Event.objects.which_references(service)[0].references) == set([service])
103 104

  
104 105
    # verify type, user and service are prefetched
......
160 161
    assert EventTypeDefinition.get_for_name('user.sso') is SSO
161 162

  
162 163
    with pytest.raises(AssertionError, match='already registered'):
164

  
163 165
        class SSO2(UserEventTypes):
164 166
            name = 'user.sso'
165 167
            label = 'Single Sign On'
......
284 286
    assert page.is_last_page
285 287
    assert not page.next_page_url
286 288
    assert page.previous_page_url
287
    assert page.events == random_events[-page.limit:]
289
    assert page.events == random_events[-page.limit :]
288 290

  
289 291
    request = rf.get('/' + page.previous_page_url)
290 292
    page = JournalForm(data=request.GET).page
......
292 294
    assert not page.is_last_page
293 295
    assert page.next_page_url
294 296
    assert page.previous_page_url
295
    assert page.events == random_events[-2 * page.limit:-page.limit]
297
    assert page.events == random_events[-2 * page.limit : -page.limit]
296 298

  
297 299
    request = rf.get('/' + page.previous_page_url)
298 300
    page = JournalForm(data=request.GET).page
......
300 302
    assert not page.is_last_page
301 303
    assert page.next_page_url
302 304
    assert page.previous_page_url
303
    assert page.events == random_events[-3 * page.limit:-2 * page.limit]
305
    assert page.events == random_events[-3 * page.limit : -2 * page.limit]
304 306

  
305 307
    request = rf.get('/' + page.next_page_url)
306 308
    form = JournalForm(data=request.GET)
......
309 311
    assert not page.is_last_page
310 312
    assert page.next_page_url
311 313
    assert page.previous_page_url
312
    assert page.events == random_events[-2 * page.limit:-page.limit]
314
    assert page.events == random_events[-2 * page.limit : -page.limit]
313 315

  
314 316
    event_after_the_first_page = random_events[page.limit]
315 317
    request = rf.get('/' + form.make_url('before_cursor', event_after_the_first_page.cursor))
......
414 416
    journal = Journal()
415 417
    journal.record('user.registration.request', email='john.doe@example.com')
416 418
    assert len(caplog.records) == 0
417
    with mock.patch.object(some_event_types['UserRegistrationRequest'], 'record', side_effect=Exception('boum')):
419
    with mock.patch.object(
420
        some_event_types['UserRegistrationRequest'], 'record', side_effect=Exception('boum')
421
    ):
418 422
        journal.record('user.registration.request', email='john.doe@example.com')
419 423
    assert len(caplog.records) == 1
420 424
    assert caplog.records[0].levelname == 'ERROR'
......
428 432
    event = Event.objects.get()
429 433

  
430 434
    event.message
431
    assert not(caplog.records)
435
    assert not (caplog.records)
432 436

  
433 437
    caplog.clear()
434 438
    with mock.patch.object(some_event_types['UserLogin'], 'get_message', side_effect=Exception('boum')):
......
443 447
    assert len(caplog.records) == 1
444 448
    assert caplog.records[0].levelname == 'ERROR'
445 449
    assert caplog.records[0].message == 'could not render message of event type "user.login"'
450

  
451

  
452
@pytest.mark.parametrize('event_type_name', ['user.login', 'user.registration'])
453
def test_statistics(db, event_type_name, freezer):
454
    from authentic2.a2_rbac.utils import get_default_ou
455
    from authentic2.a2_rbac.models import OrganizationalUnit as OU
456

  
457
    user = User.objects.create(username='john.doe', email='john.doe@example.com')
458
    user2 = User.objects.create(username='jane.doe', email='jane.doe@example.com')
459
    ou = OU.objects.create(name='Second OU')
460

  
461
    portal = Service.objects.create(name='portal', slug='portal', ou=ou)
462
    agendas = Service.objects.create(name='agendas', slug='agendas', ou=get_default_ou())
463
    forms = Service.objects.create(name='forms', slug='forms', ou=get_default_ou())
464

  
465
    method = {'how': 'password-on-https'}
466
    method2 = {'how': 'fc'}
467

  
468
    event_type = EventType.objects.get_for_name(event_type_name)
469

  
470
    freezer.move_to('2020-02-03 12:00')
471
    event = Event.objects.create(type=event_type, references=[user, portal], user=user, data=method)
472
    event = Event.objects.create(type=event_type, references=[user2, portal], user=user2, data=method)
473

  
474
    freezer.move_to('2020-02-03 13:00')
475
    event = Event.objects.create(type=event_type, references=[user, portal], user=user, data=method2)
476
    event = Event.objects.create(type=event_type, references=[user2, portal], user=user2, data=method2)
477

  
478
    freezer.move_to('2020-03-03 12:00')
479
    event = Event.objects.create(type=event_type, references=[user, portal], user=user, data=method)
480
    event = Event.objects.create(type=event_type, references=[user, agendas], user=user, data=method)
481
    event = Event.objects.create(type=event_type, references=[user, forms], user=user, data=method)
482
    event = Event.objects.create(type=event_type, user=user)
483

  
484
    event_type_definition = event_type.definition
485

  
486
    stats = event_type_definition.get_method_statistics()
487
    stats['series'].sort(key=lambda x: x['label'])
488
    assert stats == {
489
        'x_labels': ['2020-02-03T12:00:00+00:00', '2020-02-03T13:00:00+00:00', '2020-03-03T12:00:00+00:00'],
490
        'series': [
491
            {'label': 'FranceConnect', 'data': [None, 2, None]},
492
            {'label': 'none', 'data': [None, None, 1]},
493
            {'label': 'password', 'data': [2, None, 3]},
494
        ],
495
    }
496

  
497
    start = datetime(year=2020, month=2, day=3, hour=12, minute=30, tzinfo=pytz.UTC)
498
    end = datetime(year=2020, month=2, day=3, hour=13, minute=30, tzinfo=pytz.UTC)
499
    stats = event_type_definition.get_method_statistics(start=start, end=end)
500
    assert stats == {
501
        'x_labels': ['2020-02-03T13:00:00+00:00'],
502
        'series': [
503
            {'label': 'FranceConnect', 'data': [2]},
504
        ],
505
    }
506

  
507
    stats = event_type_definition.get_method_statistics('month')
508
    stats['series'].sort(key=lambda x: x['label'])
509
    assert stats == {
510
        'x_labels': ['2020-02-01T00:00:00+01:00', '2020-03-01T00:00:00+01:00'],
511
        'series': [
512
            {'label': 'FranceConnect', 'data': [2, None]},
513
            {'label': 'none', 'data': [None, 1]},
514
            {'label': 'password', 'data': [2, 3]},
515
        ],
516
    }
517

  
518
    stats = event_type_definition.get_method_statistics('year')
519
    stats['series'].sort(key=lambda x: x['label'])
520
    assert stats == {
521
        'x_labels': ['2020-01-01T00:00:00+01:00'],
522
        'series': [
523
            {'label': 'FranceConnect', 'data': [2]},
524
            {'label': 'none', 'data': [1]},
525
            {'label': 'password', 'data': [5]},
526
        ],
527
    }
528

  
529
    stats = event_type_definition.get_service_statistics('month')
530
    stats['series']['portal'].sort(key=lambda x: x['label'])
531
    assert stats == {
532
        'x_labels': ['2020-02-01T00:00:00+01:00', '2020-03-01T00:00:00+01:00'],
533
        'series': {
534
            'portal': [{'label': 'FranceConnect', 'data': [2, None]}, {'label': 'password', 'data': [2, 1]}],
535
            'agendas': [{'label': 'password', 'data': [None, 1]}],
536
            'forms': [{'label': 'password', 'data': [None, 1]}],
537
            'None': [{'label': 'none', 'data': [None, 1]}],
538
        },
539
    }
540

  
541
    stats = event_type_definition.get_service_ou_statistics('month')
542
    stats['series']['Second OU'].sort(key=lambda x: x['label'])
543
    assert stats == {
544
        'x_labels': ['2020-02-01T00:00:00+01:00', '2020-03-01T00:00:00+01:00'],
545
        'series': {
546
            'Second OU': [
547
                {'label': 'FranceConnect', 'data': [2, None]},
548
                {'label': 'password', 'data': [2, 1]},
549
            ],
550
            'Default organizational unit': [{'label': 'password', 'data': [None, 2]}],
551
            'None': [{'label': 'none', 'data': [None, 1]}],
552
        },
553
    }
446
-