0001-journal-add-event-type-statistics-47467.patch
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 |
- |