Projet

Général

Profil

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

Valentin Deniaud, 19 novembre 2020 18:29

Télécharger (13,6 ko)

Voir les différences:

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

 src/authentic2/apps/journal/models.py |  33 +++++++-
 src/authentic2/journal_event_types.py | 114 ++++++++++++++++++++++----
 tests/test_journal.py                 |  86 +++++++++++++++++++
 3 files changed, 214 insertions(+), 19 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_datetime=None, end_datetime=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_datetime:
122
            qs = qs.filter(timestamp__gte=start_datetime)
123
        if end_datetime:
124
            qs = qs.filter(timestamp__lte=end_datetime)
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/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.models import EventTypeDefinition, n_2_pairing_rev
21 22
from authentic2.apps.journal.utils import form_to_old_new
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

  
52
    @classmethod
53
    def record(cls, user, session, service, how):
54
        super().record(user=user, session=session, service=service, data={'how': how})
55

  
56
    @classmethod
57
    def get_method_statistics(cls, group_by_time='timestamp', **kwargs):
58
        stats = cls.get_statistics(group_by_time=group_by_time, group_by_field='how', **kwargs)
59

  
60
        x_labels = list(stats.distinct().values_list(group_by_time, flat=True))
61
        x_labels_indexes = {label: i for i, label in enumerate(x_labels)}
62
        empty_data = [None] * len(x_labels)
63
        y_data = {}
64

  
65
        for stat in stats:
66
            data = y_data.setdefault(stat['how'], empty_data.copy())
67
            x_label = stat[group_by_time]
68
            data[x_labels_indexes[x_label]] = stat['count']
69

  
70
        series = []
71
        for how, data in y_data.items():
72
            label = _(login_method_label(how)) if how else _('none')
73
            series.append({'label': label, 'data': data})
74

  
75
        return {
76
            'x_labels': [label.isoformat() for label in x_labels],
77
            'series': series,
78
        }
79

  
80
    @classmethod
81
    def _get_method_statistics_with_reference(cls, group_by_time, get_reference_label, **kwargs):
82
        stats = cls.get_statistics(group_by_time=group_by_time, group_by_field='how', group_by_references=True, **kwargs)
83

  
84
        x_labels = list(stats.distinct().values_list(group_by_time, flat=True))
85
        x_labels_indexes = {label: i for i, label in enumerate(x_labels)}
86
        empty_data = [0] * len(x_labels)
87
        loop_data = {}
88

  
89
        service_ct_id = ContentType.objects.get_for_model(Service).pk
90
        labels_cache = {}
91
        for stat in stats:
92
            for reference_id in stat['reference_ids'] or []:
93
                content_type_id, instance_pk = n_2_pairing_rev(reference_id)
94
                if content_type_id == service_ct_id:
95
                    label = labels_cache.get(instance_pk)
96
                    if not label:
97
                        label = get_reference_label(instance_pk)
98
                        labels_cache[instance_pk] = label
99
                    break
100
            else:
101
                label = _('None')
102
            y_data = loop_data.setdefault(label, {})
103
            data = y_data.setdefault(stat['how'], empty_data.copy())
104
            x_label = stat[group_by_time]
105
            data[x_labels_indexes[x_label]] += stat['count']
106

  
107
        series = {}
108
        for loop_label, y_data in loop_data.items():
109
            for how, data in y_data.items():
110
                y_label = _(login_method_label(how)) if how else _('none')
111
                data = [count or None for count in data]
112
                series[loop_label] = {'label': y_label, 'data': data}
113

  
114
        return {
115
            'x_labels': [label.isoformat() for label in x_labels],
116
            'series': series,
117
        }
118

  
119
    @classmethod
120
    def get_service_statistics(cls, group_by_time='timestamp', **kwargs):
121
        stats = cls.get_statistics(group_by_time=group_by_time, group_by_field='how', group_by_references=True, **kwargs)
122

  
123
        def get_reference_label(service_pk):
124
            return str(Service.objects.get(pk=service_pk))
125

  
126
        return cls._get_method_statistics_with_reference(group_by_time, get_reference_label, **kwargs)
127

  
128

  
129
    @classmethod
130
    def get_service_ou_statistics(cls, group_by_time='timestamp', **kwargs):
131

  
132
        def get_reference_label(service_pk):
133
            service = Service.objects.get(pk=service_pk)
134
            return str(service.ou)
135

  
136
        return cls._get_method_statistics_with_reference(group_by_time, get_reference_label, **kwargs)
137

  
138

  
49 139
def login_method_label(how):
50 140
    if how.startswith('password'):
51 141
        return _('password')
......
73 163
                yield name
74 164

  
75 165

  
76
class UserLogin(EventTypeWithService):
166
class UserLogin(EventTypeWithMethod):
77 167
    name = 'user.login'
78 168
    label = _('login')
79 169

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

  
84 170
    @classmethod
85 171
    def get_message(cls, event, context):
86 172
        how = event.get_data('how')
......
115 201
        return _('registration request with email "%s"') % email
116 202

  
117 203

  
118
class UserRegistration(EventTypeWithService):
204
class UserRegistration(EventTypeWithMethod):
119 205
    name = 'user.registration'
120 206
    label = _('registration')
121 207

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

  
126 208
    @classmethod
127 209
    def get_message(cls, event, context):
128 210
        how = event.get_data('how')
......
219 301
        super().record(user=user, session=session, service=service)
220 302

  
221 303

  
222
class UserServiceSSO(EventTypeWithService):
304
class UserServiceSSO(EventTypeWithMethod):
223 305
    name = 'user.service.sso'
224 306
    label = _('service single sign on')
225 307

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

  
230 308
    @classmethod
231 309
    def get_message(cls, event, context):
232 310
        service_name = cls.get_service_name(event)
tests/test_journal.py
443 443
    assert len(caplog.records) == 1
444 444
    assert caplog.records[0].levelname == 'ERROR'
445 445
    assert caplog.records[0].message == 'could not render message of event type "user.login"'
446

  
447

  
448
@pytest.mark.parametrize('event_type_name', ['user.login', 'user.registration'])
449
def test_statistics(db, event_type_name, freezer):
450
    from authentic2.a2_rbac.utils import get_default_ou
451
    from authentic2.a2_rbac.models import OrganizationalUnit as OU
452

  
453
    user = User.objects.create(username='john.doe', email='john.doe@example.com')
454
    user2 = User.objects.create(username='jane.doe', email='jane.doe@example.com')
455
    ou = OU.objects.create(name='Second OU')
456

  
457
    portal = Service.objects.create(name='portal', slug='portal', ou=ou)
458
    agendas = Service.objects.create(name='agendas', slug='agendas', ou=get_default_ou())
459
    forms = Service.objects.create(name='forms', slug='forms', ou=get_default_ou())
460

  
461
    method = {'how': 'password-on-https'}
462
    method2 = {'how': 'fc'}
463

  
464
    event_type = EventType.objects.get_for_name(event_type_name)
465

  
466
    freezer.move_to('2020-02-03 12:00')
467
    event = Event.objects.create(type=event_type, references=[user, portal], user=user, data=method)
468
    event = Event.objects.create(type=event_type, references=[user2, portal], user=user2, data=method)
469

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

  
474
    freezer.move_to('2020-03-03 12:00')
475
    event = Event.objects.create(type=event_type, references=[user, portal], user=user, data=method)
476
    event = Event.objects.create(type=event_type, references=[user, agendas], user=user, data=method)
477
    event = Event.objects.create(type=event_type, references=[user, forms], user=user, data=method)
478
    event = Event.objects.create(type=event_type, user=user)
479

  
480
    event_type_definition = event_type.definition
481

  
482
    stats = event_type_definition.get_method_statistics()
483
    assert stats == {
484
        'x_labels': ['2020-02-03T12:00:00+00:00', '2020-02-03T13:00:00+00:00', '2020-03-03T12:00:00+00:00'],
485
        'series': [
486
            {'label': 'password', 'data': [2, None, 3]},
487
            {'label': 'FranceConnect', 'data': [None, 2, None]},
488
            {'label': 'none', 'data': [None, None, 1]},
489
        ],
490
    }
491

  
492
    stats = event_type_definition.get_method_statistics('month')
493
    assert stats == {
494
        'x_labels': ['2020-02-01T00:00:00+01:00', '2020-03-01T00:00:00+01:00'],
495
        'series': [
496
            {'label': 'FranceConnect', 'data': [2, None]},
497
            {'label': 'password', 'data': [2, 3]},
498
            {'label': 'none', 'data': [None, 1]},
499
        ],
500
    }
501

  
502
    stats = event_type_definition.get_method_statistics('year')
503
    assert stats == {
504
        'x_labels': ['2020-01-01T00:00:00+01:00'],
505
        'series': [
506
            {'label': 'FranceConnect', 'data': [2]},
507
            {'label': 'password', 'data': [5]},
508
            {'label': 'none', 'data': [1]},
509
        ],
510
    }
511

  
512
    stats = event_type_definition.get_service_statistics('month')
513
    assert stats == {
514
        'x_labels': ['2020-02-01T00:00:00+01:00', '2020-03-01T00:00:00+01:00'],
515
        'series': {
516
            'portal': {'label': 'password', 'data': [2, 1]},
517
            'agendas': {'label': 'password', 'data': [None, 1]},
518
            'forms': {'label': 'password', 'data': [None, 1]},
519
            'None': {'label': 'none', 'data': [None, 1]},
520
        },
521
    }
522

  
523
    stats = event_type_definition.get_service_ou_statistics('month')
524
    assert stats == {
525
        'x_labels': ['2020-02-01T00:00:00+01:00', '2020-03-01T00:00:00+01:00'],
526
        'series': {
527
            'Second OU': {'label': 'password', 'data': [2, 1]},
528
            'Default organizational unit': {'label': 'password', 'data': [None, 2]},
529
            'None': {'label': 'none', 'data': [None, 1]},
530
        },
531
    }
446
-