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_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 |
- |