Projet

Général

Profil

0001-misc-add-journal-application-47155.patch

Benjamin Dauvergne, 14 octobre 2020 13:33

Télécharger (77,7 ko)

Voir les différences:

Subject: [PATCH 1/3] misc: add journal application (#47155)

 src/authentic2/__init__.py                    |   2 +-
 src/authentic2/{apps.py => app.py}            |   0
 src/authentic2/apps/__init__.py               |   0
 src/authentic2/apps/journal/__init__.py       |  18 +
 src/authentic2/apps/journal/admin.py          |  70 +++
 src/authentic2/apps/journal/app.py            |  29 ++
 src/authentic2/apps/journal/forms.py          | 354 ++++++++++++++
 src/authentic2/apps/journal/journal.py        |  65 +++
 .../apps/journal/migrations/0001_initial.py   | 118 +++++
 .../apps/journal/migrations/__init__.py       |   0
 src/authentic2/apps/journal/models.py         | 435 +++++++++++++++++
 src/authentic2/apps/journal/search_engine.py  | 133 ++++++
 src/authentic2/apps/journal/sql.py            |  29 ++
 .../templates/journal/date_hierarchy.html     |   8 +
 .../journal/templates/journal/event_list.html |  37 ++
 .../journal/templates/journal/pagination.html |  14 +
 src/authentic2/apps/journal/utils.py          |  32 ++
 src/authentic2/apps/journal/views.py          |  83 ++++
 src/authentic2/settings.py                    |   1 +
 tests/test_journal.py                         | 443 ++++++++++++++++++
 tests/test_journal_app/__init__.py            |   0
 tests/test_journal_app/journal_event_types.py |  27 ++
 tests/test_journal_app/urls.py                |  23 +
 tests/test_journal_app/views.py               |  33 ++
 tox.ini                                       |   1 -
 25 files changed, 1953 insertions(+), 2 deletions(-)
 rename src/authentic2/{apps.py => app.py} (100%)
 create mode 100644 src/authentic2/apps/__init__.py
 create mode 100644 src/authentic2/apps/journal/__init__.py
 create mode 100644 src/authentic2/apps/journal/admin.py
 create mode 100644 src/authentic2/apps/journal/app.py
 create mode 100644 src/authentic2/apps/journal/forms.py
 create mode 100644 src/authentic2/apps/journal/journal.py
 create mode 100644 src/authentic2/apps/journal/migrations/0001_initial.py
 create mode 100644 src/authentic2/apps/journal/migrations/__init__.py
 create mode 100644 src/authentic2/apps/journal/models.py
 create mode 100644 src/authentic2/apps/journal/search_engine.py
 create mode 100644 src/authentic2/apps/journal/sql.py
 create mode 100644 src/authentic2/apps/journal/templates/journal/date_hierarchy.html
 create mode 100644 src/authentic2/apps/journal/templates/journal/event_list.html
 create mode 100644 src/authentic2/apps/journal/templates/journal/pagination.html
 create mode 100644 src/authentic2/apps/journal/utils.py
 create mode 100644 src/authentic2/apps/journal/views.py
 create mode 100644 tests/test_journal.py
 create mode 100644 tests/test_journal_app/__init__.py
 create mode 100644 tests/test_journal_app/journal_event_types.py
 create mode 100644 tests/test_journal_app/urls.py
 create mode 100644 tests/test_journal_app/views.py
src/authentic2/__init__.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
default_app_config = 'authentic2.apps.Authentic2Config'
17
default_app_config = 'authentic2.app.Authentic2Config'
src/authentic2/apps/journal/__init__.py
1
# authentic2 - versatile identity manager
2

  
3
# Copyright (C) 2010-2020 Entr'ouvert
4
#
5
# This program is free software: you can redistribute it and/or modify it
6
# under the terms of the GNU Affero General Public License as published
7
# by the Free Software Foundation, either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU Affero General Public License for more details.
14
#
15
# You should have received a copy of the GNU Affero General Public License
16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17

  
18
default_app_config = 'authentic2.apps.journal.app.JournalAppConfig'
src/authentic2/apps/journal/admin.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import json
18

  
19
from django.contrib import admin
20
from django.utils.html import format_html
21

  
22
from .models import EventType, Event
23

  
24

  
25
class EventTypeAdmin(admin.ModelAdmin):
26
    list_display = [
27
        '__str__',
28
        'name',
29
    ]
30

  
31

  
32
admin.site.register(EventType, EventTypeAdmin)
33

  
34

  
35
class EventAdmin(admin.ModelAdmin):
36
    date_hierarchy = 'timestamp'
37
    list_filter = ['type']
38
    list_display = [
39
        'timestamp',
40
        'type',
41
        'user',
42
        'session_id_shortened',
43
        'message',
44
    ]
45
    fields = [
46
        'timestamp',
47
        'type',
48
        'user',
49
        'session_id_shortened',
50
        'formatted_references',
51
        'message',
52
        'raw_json',
53
    ]
54
    readonly_fields = [
55
        'timestamp',
56
        'user',
57
        'session_id_shortened',
58
        'formatted_references',
59
        'message',
60
        'raw_json',
61
    ]
62

  
63
    def formatted_references(self, event):
64
        return format_html('<pre>{}</pre>', event.reference_ids or [])
65

  
66
    def raw_json(self, event):
67
        return format_html('<pre>{}</pre>', json.dumps(event.data or {}, indent=4))
68

  
69

  
70
admin.site.register(Event, EventAdmin)
src/authentic2/apps/journal/app.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.apps import AppConfig
18
from django.utils.module_loading import autodiscover_modules
19
from django.utils.translation import ugettext_lazy as _
20

  
21

  
22
class JournalAppConfig(AppConfig):
23
    name = 'authentic2.apps.journal'
24
    verbose_name = _('Journal')
25

  
26
    def ready(self):
27
        from . import models
28

  
29
        autodiscover_modules('journal_event_types', register_to=models)
src/authentic2/apps/journal/forms.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from datetime import datetime
18

  
19
from django.http import QueryDict
20
from django.utils.formats import date_format
21
from django.utils.functional import cached_property
22
from django.utils.translation import ugettext_lazy as _
23
from django import forms
24

  
25
from . import models, search_engine
26

  
27

  
28
class Page:
29
    def __init__(self, form, events, is_first_page, is_last_page):
30
        self.form = form
31
        self.events = events
32
        self.is_first_page = is_first_page
33
        self.is_last_page = is_last_page
34
        self.limit = form.limit
35

  
36
    @property
37
    def previous_page_cursor(self):
38
        return None if self.is_first_page else self.events[0].cursor
39

  
40
    @property
41
    def next_page_cursor(self):
42
        return None if self.is_last_page else self.events[-1].cursor
43

  
44
    @cached_property
45
    def next_page_url(self):
46
        if self.is_last_page:
47
            return None
48
        else:
49
            return self.form.make_url('after_cursor', self.events[-1].cursor)
50

  
51
    @cached_property
52
    def first_page_url(self):
53
        return self.form.make_url('after_cursor', '0 0')
54

  
55
    @cached_property
56
    def previous_page_url(self):
57
        if self.is_first_page:
58
            return None
59
        else:
60
            return self.form.make_url('before_cursor', self.events[0].cursor)
61

  
62
    @cached_property
63
    def last_page_url(self):
64
        return self.form.make_url('before_cursor', '%s 0' % (2 ** 31 - 1))
65

  
66
    def __bool__(self):
67
        return bool(self.events)
68

  
69
    def __iter__(self):
70
        return reversed(self.events)
71

  
72

  
73
class DateHierarchy:
74
    def __init__(self, form, year=None, month=None, day=None):
75
        self.form = form
76
        self.year = year
77
        self.month = month
78
        self.day = day
79

  
80
    @property
81
    def title(self):
82
        if self.day:
83
            return date_format(self.current_datetime, 'DATE_FORMAT')
84
        elif self.month:
85
            return date_format(self.current_datetime, 'F Y')
86
        elif self.year:
87
            return str(self.year)
88

  
89
    @cached_property
90
    def back_urls(self):
91
        def helper():
92
            if self.year:
93
                yield _('All dates'), self.form.make_url(exclude=['year', 'month', 'day'])
94
                if self.month:
95
                    yield str(self.year), self.form.make_url(exclude=['month', 'day'])
96
                    current_datetime = datetime(self.year, self.month or 1, self.day or 1)
97
                    month_name = date_format(current_datetime, format='F Y').title()
98
                    if self.day:
99
                        yield month_name, self.form.make_url(exclude=['day'])
100
                        yield str(self.day), '#'
101
                    else:
102
                        yield month_name, '#'
103
                else:
104
                    yield str(self.year), '#'
105
            else:
106
                yield _('All dates'), '#'
107

  
108
        return list(helper())
109

  
110
    @property
111
    def current_datetime(self):
112
        return datetime(self.year or 1900, self.month or 1, self.day or 1)
113

  
114
    @property
115
    def month_name(self):
116
        return date_format(self.current_datetime, format='F')
117

  
118
    @cached_property
119
    def choice_urls(self):
120
        def helper():
121
            if self.day:
122
                return
123
            elif self.month:
124
                for day in self.form.days:
125
                    yield str(day), self.form.make_url('day', day)
126
            elif self.year:
127
                for month in self.form.months:
128
                    dt = datetime(self.year, month, 1)
129
                    month_name = date_format(dt, format='F')
130
                    yield month_name, self.form.make_url('month', month, exclude=['day'])
131
            else:
132
                for year in self.form.years:
133
                    yield str(year), self.form.make_url('year', year, exclude=['month', 'day'])
134

  
135
        return list(helper())
136

  
137
    @property
138
    def choice_name(self):
139
        if self.day:
140
            return
141
        elif self.month:
142
            return _('Days of %s') % self.month_name
143
        elif self.year:
144
            return _('Months of %s') % self.year
145
        else:
146
            return _('Years')
147

  
148

  
149
class SearchField(forms.CharField):
150
    type = 'search'
151

  
152

  
153
class JournalForm(forms.Form):
154
    year = forms.CharField(label=_('year'), widget=forms.HiddenInput(), required=False)
155

  
156
    month = forms.CharField(label=_('month'), widget=forms.HiddenInput(), required=False)
157

  
158
    day = forms.CharField(label=_('day'), widget=forms.HiddenInput(), required=False)
159

  
160
    after_cursor = forms.CharField(widget=forms.HiddenInput(), required=False)
161

  
162
    before_cursor = forms.CharField(widget=forms.HiddenInput(), required=False)
163

  
164
    search = SearchField(required=False, label='')
165

  
166
    search_engine_class = search_engine.JournalSearchEngine
167

  
168
    def __init__(self, *args, **kwargs):
169
        self.queryset = kwargs.pop('queryset', None)
170
        if self.queryset is None:
171
            self.queryset = models.Event.objects.all()
172
        self.limit = kwargs.pop('limit', 20)
173
        search_engine_class = kwargs.pop('search_engine_class', None)
174
        if search_engine_class:
175
            self.search_engine_class = search_engine_class
176
        super().__init__(*args, **kwargs)
177

  
178
    @cached_property
179
    def years(self):
180
        self.is_valid()
181
        return [dt.year for dt in self.queryset.datetimes('timestamp', 'year')]
182

  
183
    @cached_property
184
    def months(self):
185
        self.is_valid()
186
        if self.cleaned_data.get('year'):
187
            return [
188
                dt.month
189
                for dt in self.queryset.filter(timestamp__year=self.cleaned_data['year']).datetimes(
190
                    'timestamp', 'month'
191
                )
192
            ]
193
        return []
194

  
195
    @cached_property
196
    def days(self):
197
        self.is_valid()
198
        if self.cleaned_data.get('month') and self.cleaned_data.get('year'):
199
            return [
200
                dt.day
201
                for dt in self.queryset.filter(
202
                    timestamp__year=self.cleaned_data['year'], timestamp__month=self.cleaned_data['month']
203
                ).datetimes('timestamp', 'day')
204
            ]
205
        return []
206

  
207
    def clean_integer_value(name):
208
        def clean(self):
209
            try:
210
                return int(self.cleaned_data[name])
211
            except ValueError:
212
                return None
213

  
214
        return clean
215

  
216
    clean_year = clean_integer_value('year')
217
    clean_month = clean_integer_value('month')
218
    clean_day = clean_integer_value('day')
219
    del clean_integer_value
220

  
221
    def clean(self):
222
        super().clean()
223

  
224
        year = self.cleaned_data.get('year')
225
        if year not in self.years:
226
            self.cleaned_data['year'] = None
227
        month = self.cleaned_data.get('month')
228
        if month not in self.months:
229
            self.cleaned_data['month'] = None
230
        day = self.cleaned_data.get('day')
231
        if day not in self.days:
232
            self.cleaned_data['day'] = None
233

  
234
    def clean_cursor(name):
235
        def clean(self):
236
            return models.EventCursor.parse(self.cleaned_data[name])
237

  
238
        return clean
239

  
240
    clean_after_cursor = clean_cursor('after_cursor')
241
    clean_before_cursor = clean_cursor('before_cursor')
242
    del clean_cursor
243

  
244
    def clean_search(self):
245
        self.cleaned_data['_search_query'] = self.search_engine_class().query(
246
            query_string=self.cleaned_data['search']
247
        )
248
        return self.cleaned_data['search']
249

  
250
    def get_queryset(self, limit=None):
251
        self.is_valid()
252

  
253
        qs = self.queryset
254
        year = self.cleaned_data.get('year')
255
        month = self.cleaned_data.get('month')
256
        day = self.cleaned_data.get('day')
257
        search_query = self.cleaned_data.get('_search_query')
258

  
259
        if year:
260
            qs = qs.filter(timestamp__year=year)
261
        if month:
262
            qs = qs.filter(timestamp__month=month)
263
        if day:
264
            qs = qs.filter(timestamp__day=day)
265
        if search_query:
266
            qs = qs.filter(search_query)
267
        return qs
268

  
269
    def make_querydict(self, name=None, value=None, exclude=()):
270
        querydict = QueryDict(mutable=True)
271
        for k, v in self.cleaned_data.items():
272
            if k.startswith('_'):
273
                continue
274
            if k in exclude:
275
                continue
276
            if v:
277
                querydict[k] = str(v)
278

  
279
        if name:
280
            if name in ['after_cursor', 'before_cursor']:
281
                querydict.pop('after_cursor', None)
282
                querydict.pop('before_cursor', None)
283
            assert name in self.fields
284
            assert value is not None
285
            querydict[name] = value
286
        return querydict
287

  
288
    def make_url(self, name=None, value=None, exclude=()):
289
        return '?' + self.make_querydict(name=name, value=value, exclude=exclude).urlencode()
290

  
291
    @cached_property
292
    def page(self):
293
        self.is_valid()
294

  
295
        after_cursor = self.cleaned_data['after_cursor']
296
        before_cursor = self.cleaned_data['before_cursor']
297
        first = False
298
        last = False
299
        limit = self.limit
300

  
301
        qs = self.get_queryset()
302
        if after_cursor:
303
            page = list(qs[after_cursor : (limit + 2)])
304
            first = not (qs[-1:after_cursor])
305
            if len(page) > limit:
306
                last = len(page) != (limit + 2)
307
                if page[0].cursor == after_cursor:
308
                    page = page[1 : (limit + 1)]
309
                else:
310
                    page = page[:limit]
311
            else:
312
                last = True
313
                before_cursor = after_cursor if not page else page[-1].cursor
314
                page = list(qs[-(limit + 1) : before_cursor])
315
                first = len(page) < (limit + 1)
316
                page = page[-limit:]
317
        elif before_cursor:
318
            page = list(qs[-(limit + 2) : before_cursor])
319
            last = not (qs[before_cursor:1])
320
            if len(page) > limit:
321
                first = len(page) != (limit + 2)
322
                page = page[-(limit + 1) : -1]
323
            else:
324
                first = True
325
                after_cursor = before_cursor if not page else page[0].cursor
326
                page = list(qs[after_cursor : (limit + 1)])
327
                last = len(page) < (limit + 1)
328
                page = page[:limit]
329
        else:
330
            qs = qs.order_by('-timestamp', '-id')
331
            page = qs[: (limit + 1) : -1]
332
            first = len(page) <= limit
333
            last = True
334
            page = page[-limit:]
335
        models.prefetch_events_references(page)
336
        if page:
337
            self.data = self.data.copy()
338
            self.cleaned_data['after_cursor'] = self.data['after_cursor'] = page[0].cursor.minus_one()
339
            self.cleaned_data['before_cursor'] = ''
340
        return Page(self, page, first, last)
341

  
342
    @cached_property
343
    def date_hierarchy(self):
344
        self.is_valid()
345
        return DateHierarchy(
346
            self,
347
            year=self.cleaned_data['year'],
348
            month=self.cleaned_data['month'],
349
            day=self.cleaned_data['day'],
350
        )
351

  
352
    @property
353
    def url(self):
354
        return self.make_url()
src/authentic2/apps/journal/journal.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import inspect
18
import logging
19

  
20
from authentic2.apps.journal.models import EventTypeDefinition
21

  
22
logger = logging.getLogger(__name__)
23

  
24

  
25
class Journal:
26
    def __init__(self, request=None, user=None, session=None, service=None):
27
        self.request = request
28
        self._user = user
29
        self._session = session
30

  
31
    @property
32
    def user(self):
33
        return self._user or (
34
            self.request.user
35
            if hasattr(self.request, 'user') and self.request.user.is_authenticated
36
            else None
37
        )
38

  
39
    @property
40
    def session(self):
41
        return self._session or (self.request.session if hasattr(self.request, 'session') else None)
42

  
43
    def massage_kwargs(self, record_parameters, kwargs):
44
        for key in ['user', 'session']:
45
            if key in record_parameters and key not in kwargs:
46
                kwargs[key] = getattr(self, key)
47
        return kwargs
48

  
49
    def record(self, event_type_name, **kwargs):
50
        evd_class = EventTypeDefinition.get_for_name(event_type_name)
51
        if evd_class is None:
52
            logger.error('invalid event_type name "%s"', event_type_name)
53
            return
54
        try:
55
            record = evd_class.record
56
            record_signature = inspect.signature(record)
57
            parameters = record_signature.parameters
58

  
59
            kwargs = self.massage_kwargs(parameters, kwargs)
60
            record(**kwargs)
61
        except Exception:
62
            logger.exception('failure to record event "%s"', event_type_name)
63

  
64

  
65
journal = Journal()
src/authentic2/apps/journal/migrations/0001_initial.py
1
# Generated by Django 2.2.15 on 2020-08-23 16:56
2

  
3
from django.conf import settings
4
import django.contrib.postgres.fields
5
import django.contrib.postgres.fields.jsonb
6
from django.db import migrations, models
7
import django.db.models.deletion
8
from django.utils import timezone
9

  
10

  
11
class Migration(migrations.Migration):
12

  
13
    initial = True
14

  
15
    dependencies = [
16
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
        ('authentic2', '0027_remove_deleteduser'),
18
        ('sessions', '0001_initial'),
19
    ]
20

  
21
    operations = [
22
        migrations.CreateModel(
23
            name='EventType',
24
            fields=[
25
                (
26
                    'id',
27
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
28
                ),
29
                ('name', models.SlugField(max_length=256, unique=True, verbose_name='name')),
30
            ],
31
            options={
32
                'verbose_name': 'event type',
33
                'verbose_name_plural': 'event types',
34
                'ordering': ('name',),
35
            },
36
        ),
37
        migrations.CreateModel(
38
            name='Event',
39
            fields=[
40
                (
41
                    'id',
42
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
43
                ),
44
                (
45
                    'timestamp',
46
                    models.DateTimeField(
47
                        default=timezone.now, editable=False, blank=True, verbose_name='timestamp'
48
                    ),
49
                ),
50
                (
51
                    'reference_ids',
52
                    django.contrib.postgres.fields.ArrayField(
53
                        base_field=models.BigIntegerField(),
54
                        null=True,
55
                        size=None,
56
                        verbose_name='reference ids',
57
                    ),
58
                ),
59
                (
60
                    'reference_ct_ids',
61
                    django.contrib.postgres.fields.ArrayField(
62
                        base_field=models.IntegerField(),
63
                        null=True,
64
                        size=None,
65
                        verbose_name='reference ct ids',
66
                    ),
67
                ),
68
                ('data', django.contrib.postgres.fields.jsonb.JSONField(null=True, verbose_name='data')),
69
                (
70
                    'session',
71
                    models.ForeignKey(
72
                        blank=True,
73
                        db_constraint=False,
74
                        null=True,
75
                        on_delete=django.db.models.deletion.DO_NOTHING,
76
                        to='sessions.Session',
77
                        verbose_name='session',
78
                    ),
79
                ),
80
                (
81
                    'type',
82
                    models.ForeignKey(
83
                        on_delete=django.db.models.deletion.PROTECT,
84
                        to='journal.EventType',
85
                        verbose_name='type',
86
                    ),
87
                ),
88
                (
89
                    'user',
90
                    models.ForeignKey(
91
                        blank=True,
92
                        db_constraint=False,
93
                        null=True,
94
                        on_delete=django.db.models.deletion.DO_NOTHING,
95
                        to=settings.AUTH_USER_MODEL,
96
                        verbose_name='user',
97
                    ),
98
                ),
99
            ],
100
            options={
101
                'verbose_name': 'event',
102
                'verbose_name_plural': 'events',
103
                'ordering': ('timestamp', 'id'),
104
            },
105
        ),
106
        migrations.RunSQL(
107
            [
108
                'CREATE INDEX journal_event_timestamp_id_idx ON journal_event USING BRIN("timestamp", "id");',
109
                'CREATE INDEX journal_event_reference_ids_idx ON journal_event USING GIN("reference_ids");',
110
                'CREATE INDEX journal_event_reference_ct_ids_idx ON journal_event USING GIN("reference_ct_ids");',
111
            ],
112
            [
113
                'DROP INDEX journal_event_reference_ct_ids_idx;',
114
                'DROP INDEX journal_event_reference_ids_idx;',
115
                'DROP INDEX journal_event_timestamp_id_idx;',
116
            ]
117
        ),
118
    ]
src/authentic2/apps/journal/models.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from collections import defaultdict
18
from contextlib import contextmanager
19
from datetime import datetime, timedelta
20
import itertools
21
import logging
22
import re
23

  
24
from django.conf import settings
25
from django.contrib.auth import get_user_model
26
from django.contrib.postgres.fields import ArrayField, JSONField
27
from django.contrib.contenttypes.models import ContentType
28
from django.core.exceptions import ObjectDoesNotExist
29
from django.db import models
30
from django.db.models import QuerySet, Q, F, Value
31
from django.utils.translation import ugettext_lazy as _
32
from django.utils.timezone import utc, now
33

  
34
from authentic2.decorators import GlobalCache
35

  
36
from . import sql
37

  
38
logger = logging.getLogger(__name__)
39

  
40
User = get_user_model()
41

  
42
_registry = {}
43

  
44

  
45
@contextmanager
46
def clean_registry():
47
    global _registry
48

  
49
    old_registry = _registry
50
    _registry = {}
51
    yield
52
    _registry = old_registry
53

  
54

  
55
class EventTypeDefinitionMeta(type):
56
    def __new__(cls, name, bases, namespace, **kwargs):
57
        global _registry
58

  
59
        new_cls = type.__new__(cls, name, bases, namespace, **kwargs)
60

  
61
        name = namespace.get('name')
62

  
63
        if name:
64
            assert (
65
                new_cls.retention_days is None or new_cls.retention_days >= 0
66
            ), 'retention_days must be None or >= 0'
67
            assert new_cls.name, 'name is missing'
68
            assert re.match(r'^[a-z_]+(?:\.[a-z_]+)*$', new_cls.name), (
69
                '%r is not proper event type name' % new_cls.name
70
            )
71
            assert new_cls.label, 'label is missing'
72

  
73
            assert new_cls.name not in _registry, 'name %r is already registered' % new_cls.name
74

  
75
            _registry[new_cls.name] = new_cls
76
        return new_cls
77

  
78

  
79
class EventTypeDefinition(metaclass=EventTypeDefinitionMeta):
80
    name = ''
81
    label = None
82
    # used to group type of events
83
    # how long to keep this type of events
84
    retention_days = None
85

  
86
    @classmethod
87
    def record(cls, user=None, session=None, references=None, data=None):
88
        event_type = EventType.objects.get_for_name(cls.name)
89

  
90
        Event.objects.create(
91
            type=event_type,
92
            user=user,
93
            session_id=session and session.session_key,
94
            references=references or None,  # NULL values take less space
95
            data=data or None,  # NULL values take less space
96
        )
97

  
98
    @classmethod
99
    def get_for_name(cls, name):
100
        return _registry.get(name)
101

  
102
    @classmethod
103
    def search_by_name(self, name):
104
        for evd_name, evd in _registry.items():
105
            if evd_name == name or evd_name.rsplit('.', 1)[-1] == name:
106
                yield evd
107

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

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

  
115

  
116
@GlobalCache
117
def event_type_cache(name):
118
    event_type, created = EventType.objects.get_or_create(name=name)
119
    return event_type
120

  
121

  
122
class EventTypeManager(models.Manager):
123
    def get_for_name(self, name):
124
        return event_type_cache(name)
125

  
126

  
127
class EventType(models.Model):
128
    name = models.SlugField(verbose_name=_('name'), max_length=256, unique=True)
129

  
130
    @property
131
    def definition(self):
132
        return EventTypeDefinition.get_for_name(self.name)
133

  
134
    def __str__(self):
135
        definition = self.definition
136
        if definition:
137
            return str(definition.label)
138
        else:
139
            return '%s (definition not found)' % self.name
140

  
141
    objects = EventTypeManager()
142

  
143
    class Meta:
144
        verbose_name = _('event type')
145
        verbose_name_plural = _('event types')
146
        ordering = ('name',)
147

  
148

  
149
class EventQuerySet(QuerySet):
150
    @classmethod
151
    def _which_references_query(cls, instance_or_model_class_or_queryset):
152
        if isinstance(instance_or_model_class_or_queryset, type) and issubclass(
153
            instance_or_model_class_or_queryset, models.Model
154
        ):
155
            ct = ContentType.objects.get_for_model(instance_or_model_class_or_queryset)
156
            q = Q(reference_ct_ids__contains=[ct.pk])
157
            # users can also be references by the user_id column
158
            if instance_or_model_class_or_queryset is User:
159
                q |= Q(user__isnull=False)
160
            return q
161
        elif isinstance(instance_or_model_class_or_queryset, QuerySet):
162
            qs = instance_or_model_class_or_queryset
163
            model = qs.model
164
            ct_model = ContentType.objects.get_for_model(model)
165
            qs_array = qs.values_list(Value(ct_model.id << 32) + F('pk'), flat=True)
166
            q = Q(reference_ids__overlap=sql.ArraySubquery(qs_array))
167
            if issubclass(model, User):
168
                q = q | Q(user=qs)
169
        else:
170
            instance = instance_or_model_class_or_queryset
171
            q = Q(reference_ids__contains=[reference_integer(instance)])
172
            if isinstance(instance, User):
173
                q = q | Q(user=instance)
174
        return q
175

  
176
    def which_references(self, instance_or_queryset):
177
        return self.filter(self._which_references_query(instance_or_queryset))
178

  
179
    def from_cursor(self, cursor):
180
        return self.filter(
181
            Q(timestamp=cursor.timestamp, id__gte=cursor.event_id) | Q(timestamp__gt=cursor.timestamp)
182
        )
183

  
184
    def to_cursor(self, cursor):
185
        return self.filter(
186
            Q(timestamp=cursor.timestamp, id__lte=cursor.event_id) | Q(timestamp__lt=cursor.timestamp)
187
        )
188

  
189
    def __getitem__(self, i):
190
        # slice by cursor:
191
        # [cursor..20] or [-20..cursor]
192
        # it simplifies pagination
193
        if isinstance(i, slice) and i.step is None:
194
            _slice = i
195
            if isinstance(_slice.start, EventCursor) and isinstance(_slice.stop, int) and _slice.stop >= 0:
196
                return self.from_cursor(_slice.start)[: _slice.stop]
197
            if isinstance(_slice.start, int) and _slice.start <= 0 and isinstance(_slice.stop, EventCursor):
198
                qs = self.order_by('-timestamp', '-id').to_cursor(_slice.stop)[: -_slice.start]
199
                return list(reversed(qs))
200
        return super().__getitem__(i)
201

  
202
    def prefetch_references(self):
203
        prefetch_events_references(self)
204
        return self
205

  
206

  
207
class EventManager(models.Manager):
208
    def get_queryset(self):
209
        return super().get_queryset().select_related('type', 'user')
210

  
211

  
212
# contains/overlap operator on postresql ARRAY do not really support nested arrays,
213
# so we cannot use them to query an array generic foreign keys repsented as two
214
# elements integers arrays like '{{1,100},{2,300}}' as any integer will match.
215
# ex.:
216
# authentic_multitenant=# select '{{1,2}}'::int[] && '{{1,3}}'::int[];
217
#  ?column?
218
# ----------
219
#  t
220
# (1 line)
221
#
222
# To work around this limitation we map pair of integers (content_type.pk,
223
# instance.pk) to a corresponding unique 64-bit integer using the reversible
224
# mapping (content_type.pk << 32 + instance.pk).
225

  
226

  
227
def n_2_pairing(a, b):
228
    return a * 2 ** 32 + b
229

  
230

  
231
def n_2_pairing_rev(n):
232
    return (n >> 32, n & (2 ** 32 - 1))
233

  
234

  
235
def reference_integer(instance):
236
    return n_2_pairing(ContentType.objects.get_for_model(instance).pk, instance.pk)
237

  
238

  
239
class Event(models.Model):
240
    timestamp = models.DateTimeField(
241
        verbose_name=_('timestamp'),
242
        default=now,
243
        editable=False,
244
        blank=True)
245

  
246
    user = models.ForeignKey(
247
        verbose_name=_('user'),
248
        to=User,
249
        on_delete=models.DO_NOTHING,
250
        db_constraint=False,
251
        blank=True,
252
        null=True,
253
    )
254

  
255
    session = models.ForeignKey(
256
        verbose_name=_('session'),
257
        to='sessions.Session',
258
        on_delete=models.DO_NOTHING,
259
        db_constraint=False,
260
        blank=True,
261
        null=True,
262
    )
263

  
264
    type = models.ForeignKey(verbose_name=_('type'), to=EventType, on_delete=models.PROTECT)
265

  
266
    reference_ids = ArrayField(
267
        verbose_name=_('reference ids'), base_field=models.BigIntegerField(), null=True,
268
    )
269

  
270
    reference_ct_ids = ArrayField(
271
        verbose_name=_('reference ct ids'), base_field=models.IntegerField(), null=True,
272
    )
273

  
274
    data = JSONField(verbose_name=_('data'), null=True)
275

  
276
    objects = EventManager.from_queryset(EventQuerySet)()
277

  
278
    def __init__(self, *args, **kwargs):
279
        references = kwargs.pop('references', ())
280
        super().__init__(*args, **kwargs)
281
        for reference in references or ():
282
            self.add_reference_to_instance(reference)
283

  
284
    def add_reference_to_instance(self, instance):
285
        self.reference_ids = self.reference_ids or []
286
        self.reference_ct_ids = self.reference_ct_ids or []
287
        if instance is not None:
288
            self.reference_ids.append(reference_integer(instance))
289
            self.reference_ct_ids.append(ContentType.objects.get_for_model(instance).pk)
290
        else:
291
            self.reference_ids.append(0)
292
            self.reference_ct_ids.append(0)
293

  
294
    def get_reference_ids(self):
295
        return map(n_2_pairing_rev, self.reference_ids or ())
296

  
297
    @property
298
    def references(self):
299
        if not hasattr(self, '_references_cache'):
300
            self._references_cache = []
301
            for content_type_id, instance_pk in self.get_reference_ids():
302
                if content_type_id != 0:
303
                    content_type = ContentType.objects.get_for_id(content_type_id)
304
                    try:
305
                        self._references_cache.append(content_type.get_object_for_this_type(pk=instance_pk))
306
                        continue
307
                    except ObjectDoesNotExist:
308
                        pass
309
                self._references_cache.append(None)
310
        return self._references_cache
311

  
312
    @property
313
    def cursor(self):
314
        return EventCursor.from_event(self)
315

  
316
    def __repr__(self):
317
        return '<Event id:%s %s %s>' % (self.id, self.timestamp, self.type.name)
318

  
319
    @classmethod
320
    def cleanup(cls):
321
        '''Expire old events by default retention days or customized at the
322
           EventTypeDefinition level.'''
323
        event_types_by_retention_days = defaultdict(set)
324
        default_retention_days = getattr(settings, 'JOURNAL_DEFAULT_RETENTION_DAYS', 365 * 2)
325
        for event_type in EventType.objects.all():
326
            evd = event_type.definition
327
            retention_days = evd.retention_days if evd else None
328
            if retention_days == 0:
329
                # do not expire
330
                continue
331
            if retention_days is None:
332
                retention_days = default_retention_days
333
            event_types_by_retention_days[retention_days].add(event_type)
334

  
335
        for retention_days, event_types in event_types_by_retention_days.items():
336
            threshold = now() - timedelta(days=retention_days)
337
            Event.objects.filter(type__in=event_types).filter(timestamp__lt=threshold).delete()
338

  
339
    @property
340
    def session_id_shortened(self):
341
        return (self.session_id and self.session_id[:6]) or '-'
342

  
343
    @property
344
    def message(self):
345
        return self.message_in_context(None)
346

  
347
    def message_in_context(self, context):
348
        if self.type.definition:
349
            try:
350
                return self.type.definition.get_message(self, context)
351
            except Exception:
352
                logger.exception('could not render message of event type "%s"', self.type.name)
353
        return self.type.name
354

  
355
    def get_data(self, key, default=None):
356
        return (self.data or {}).get(key, default)
357

  
358
    def get_typed_references(self, *reference_types):
359
        count = 0
360
        for reference_type, reference in zip(reference_types, self.references):
361
            if reference_type is None:
362
                yield None
363
            else:
364
                if isinstance(reference, reference_type):
365
                    yield reference
366
                else:
367
                    yield None
368
            count += 1
369
        for i in range(len(reference_types) - count):
370
            yield None
371

  
372
    class Meta:
373
        verbose_name = _('event')
374
        verbose_name_plural = _('events')
375
        ordering = ('timestamp', 'id')
376

  
377

  
378
class EventCursor(str):
379
    '''Represents a point in the journal'''
380

  
381
    def __new__(cls, value):
382
        self = super().__new__(cls, value)
383
        try:
384
            timestamp, event_id = value.split(' ', 1)
385
            timestamp = float(timestamp)
386
            event_id = int(event_id)
387
            timestamp = datetime.fromtimestamp(timestamp, tz=utc)
388
        except ValueError as e:
389
            raise ValueError('invalid event cursor') from e
390
        self.timestamp = timestamp
391
        self.event_id = event_id
392
        return self
393

  
394
    @classmethod
395
    def parse(cls, value):
396
        try:
397
            return cls(value)
398
        except ValueError:
399
            return None
400

  
401
    @classmethod
402
    def from_event(cls, event):
403
        assert event.id is not None
404
        assert event.timestamp is not None
405
        cursor = super().__new__(cls, '%s %s' % (event.timestamp.timestamp(), event.id))
406
        cursor.timestamp = event.timestamp
407
        cursor.event_id = event.id
408
        return cursor
409

  
410
    def minus_one(self):
411
        return EventCursor('%s %s' % (self.timestamp.timestamp(), self.event_id - 1))
412

  
413

  
414
def prefetch_events_references(events):
415
    '''Prefetch references on an iterable of events, prevent N+1 queries problem.'''
416
    grouped_references = defaultdict(set)
417
    references = {}
418

  
419
    # group reference ids
420
    for event in events:
421
        for content_type_id, instance_pk in event.get_reference_ids():
422
            grouped_references[content_type_id].add(instance_pk)
423

  
424
    # make batched queries for each CT
425
    for content_type_id, instance_pks in grouped_references.items():
426
        content_type = ContentType.objects.get_for_id(content_type_id)
427
        for instance in content_type.get_all_objects_for_this_type(pk__in=instance_pks):
428
            references[(content_type_id, instance.pk)] = instance
429

  
430
    # assign references to events
431
    for event in events:
432
        event._references_cache = [
433
            references.get((content_type_id, instance_pk))
434
            for content_type_id, instance_pk in event.get_reference_ids()
435
        ]
src/authentic2/apps/journal/search_engine.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from functools import reduce
18
import re
19

  
20
from django.contrib.auth import get_user_model
21
from django.db.models import Q
22
from django.utils.translation import ugettext_lazy as _
23

  
24
from . import models
25

  
26
User = get_user_model()
27

  
28
QUOTED_RE = re.compile(r'^[a-z0-9_-]*:"[^"]*"$')
29
LEXER_RE = re.compile(r'([a-z0-9_-]*:(?:"[^"]*"|[^ ]*)|[^\s]*)\s*')
30

  
31

  
32
class SearchEngine:
33
    # https://stackoverflow.com/a/35894763/6686829
34
    q_true = ~Q(pk__in=[])
35
    q_false = Q(pk__in=[])
36

  
37
    def lexer(self, query_string):
38
        # quote can be used to passe string containing spaces to prefix directives, like :
39
        # username:"john doe", used anywhere else they are part of words.
40
        # ex. « john "doe » gives the list ['john', '"doe']
41
        for lexem in LEXER_RE.findall(query_string):
42
            if not lexem:
43
                continue
44
            if QUOTED_RE.match(lexem):
45
                lexem = lexem.replace('"', '')
46
            yield lexem
47

  
48
    def query(self, query_string):
49
        lexems = list(self.lexer(query_string))
50
        print('lexems', lexems)
51
        queries = list(self.lexems_queries(lexems))
52
        return reduce(Q.__and__, queries, self.q_true)
53

  
54
    def lexems_queries(self, lexems):
55
        unmatched_lexems = []
56
        for lexem in lexems:
57
            queries = list(self.lexem_queries(lexem))
58
            if queries:
59
                yield reduce(Q.__or__, queries)
60
            else:
61
                unmatched_lexems.append(lexem)
62
        if unmatched_lexems:
63
            query = self.unmatched_lexems_query(unmatched_lexems)
64
            if query:
65
                yield query
66
            else:
67
                yield self.q_false
68

  
69
    def unmatched_lexems_query(self, unmatched_lexems):
70
        return None
71

  
72
    def lexem_queries(self, lexem):
73
        yield from self.lexem_queries_by_prefix(lexem)
74

  
75
    def lexem_queries_by_prefix(self, lexem):
76
        if ':' not in lexem:
77
            return
78
        prefix = lexem.split(':', 1)[0]
79

  
80
        method_name = 'search_by_' + prefix.replace('-', '_')
81
        if not hasattr(self, method_name):
82
            return
83

  
84
        yield from getattr(self, method_name)(lexem[len(prefix) + 1 :])
85

  
86
    @classmethod
87
    def documentation(cls):
88
        yield _('You can use quote around to preserve spaces.')
89
        yield _('You can use colon terminated prefixes to make special searches.')
90
        for name in dir(cls):
91
            documentation = getattr(getattr(cls, name), 'documentation', None)
92
            if documentation:
93
                yield documentation
94

  
95

  
96
class JournalSearchEngine(SearchEngine):
97
    def search_by_session(self, session_id):
98
        yield Q(session__session_key__startswith=session_id)
99
    search_by_session.documentation = _(
100
        '''\
101
You can use <tt>session:abcd</tt> to find all events related to the session whose key starts with <tt>abcd</tt>.'''
102
    )
103

  
104
    def search_by_event(self, event_name):
105
        q = self.q_false
106
        for evd in models.EventTypeDefinition.search_by_name(event_name.lower()):
107
            q |= Q(type__name=evd.name)
108
        yield q
109
    search_by_event.documentation = _(
110
        '''\
111
You can use <tt>event:login</tt> to find all events of type <tt>login</tt>.'''
112
    )
113

  
114
    def query_for_users(self, users):
115
        return models.EventQuerySet._which_references_query(users)
116

  
117
    def search_by_email(self, email):
118
        users = User.objects.filter(email__istartswith=email.lower())
119
        yield (self.query_for_users(users) | Q(data__email__startswith=email.lower()))
120
    search_by_event.documentation = _(
121
        '''\
122
You can use <tt>email:john.doe@example.com</tt> to find all events related \
123
to users whose email address starts with <tt>john.doe@example.com</tt>.'''
124
    )
125

  
126
    def search_by_username(self, lexem):
127
        users = User.objects.filter(username__startswith=lexem)
128
        yield (self.query_for_users(users) | Q(data__username__startswith=lexem.lower()))
129
    search_by_username.documentation = _(
130
        '''\
131
You can use <tt>username:john</tt> to find all events related \
132
to users whose username starts with <tt>john</tt>.'''
133
    )
src/authentic2/apps/journal/sql.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.db.models import Expression, Func, Subquery, Value
18
from django.contrib.postgres.fields import ArrayField
19

  
20

  
21
class ArraySubquery(Func):
22
    """A Postgres ARRAY() expression"""
23

  
24
    function = 'ARRAY'
25
    arity = 1
26

  
27
    def __init__(self, expression, output_field=None):
28
        expression = Subquery(expression)
29
        super().__init__(expression, output_field=ArrayField(output_field))
src/authentic2/apps/journal/templates/journal/date_hierarchy.html
1
{% for caption, url in date_hierarchy.back_urls %}
2
    <a class="date-hierarchy--back-url" href="{{ url }}">{{ caption }}</a>
3
{% endfor %}
4
{% if date_hierarchy.choice_urls %}
5
    {% for caption, url in date_hierarchy.choice_urls %}
6
        <a class="date-hierarchy--choice" href="{{ url }}">{{ caption }}</a>
7
    {% endfor %}
8
{% endif %}
src/authentic2/apps/journal/templates/journal/event_list.html
1
{% load i18n %}
2
<div class="table-container">
3
    {% for event in page %}
4
        {% if forloop.first %}
5
            <table class="main">
6
                <thead>
7
                    <tr>
8
                        <th>{% trans "Timestamp" %}</th>
9
                        <th>{% trans "User" %}</th>
10
                        <th>{% trans "Session" %}</th>
11
                        <th>{% trans "Service" %}</th>
12
                        <th>{% trans "Message" %}</th>
13
                    </tr>
14
                </thead>
15
                <tbody>
16
        {% endif %}
17
                    <tr data-event-id="{{ event.id }}" data-event-cursor="{{ event.cursor }}" data-event-type="{{ event.type.name }}">
18
                        <td class="journal-list--timestamp-column">{% block event-timestamp %}{{ event.timestamp }}{% endblock %}</td>
19
                        <td class="journal-list--user-column" {% if event.user %}data-user-id="{{ event.user.id }}"{% endif %}>{% block event-user %}{{ event.user.get_full_name|default:"-" }}{% endblock %}</td>
20
                        <td class="journal-list--session-column">{% block event-session %}{{ event.session_id_shortened|default:"-" }}{% endblock %}</td>
21
                        <td class="journal-list--service-column">{% block event-service %}{{ event.service|default:"-" }}{% endblock %}</td>
22
                        <td class="journal-list--message-column">{% block event-message %}{{ event.message|default:"-" }}{% endblock %}</td>
23
                    </tr>
24
        {% if forloop.last %}
25
                </tbody>
26
            </table>
27
        {% endif %}
28
    {% empty %}
29
        {% block empty %}
30
            <div>
31
                <p>{% trans "Journal is empty." %}</p>
32
            </div>
33
        {% endblock %}
34
    {% endfor %}
35
    {% include "journal/pagination.html" with page=page %}
36
</div>
37

  
src/authentic2/apps/journal/templates/journal/pagination.html
1
{% load i18n %}
2
<p class="paginator">
3
    {% if not page.is_first_page %}
4
        <a href="{{ page.first_page_url }}">{% trans "First page" %}</a>
5
        <a href="{{ page.previous_page_url }}">{% trans "Previous page" %}</a>
6
    {% endif %}
7
    {% if not page.is_first_page and not page.is_last_page %}
8
9
    {% endif %}
10
    {% if not page.is_last_page %}
11
        <a href="{{ page.next_page_url }}">{% trans "Next page" %}</a>
12
        <a href="{{ page.last_page_url }}">{% trans "Last page" %}</a>
13
    {% endif %}
14
</p>
src/authentic2/apps/journal/utils.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17

  
18
def _json_value(value):
19
    if isinstance(value, (dict, list, str, int, bool)) or value is None:
20
        return value
21
    return str(value)
22

  
23

  
24
def form_to_old_new(form):
25
    old = {}
26
    new = {}
27
    for key in form.changed_data:
28
        old_value = form.initial.get(key)
29
        if old_value is not None:
30
            old[key] = _json_value(old_value)
31
        new[key] = _json_value(form.cleaned_data.get(key))
32
    return {'old': old, 'new': new}
src/authentic2/apps/journal/views.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.views.generic import TemplateView
18
from django.views.generic.edit import FormMixin
19

  
20
from . import models, forms
21

  
22

  
23
class JournalView(FormMixin, TemplateView):
24
    form_class = forms.JournalForm
25
    limit = 20
26

  
27
    def get_events(self):
28
        return models.Event.objects.all()
29

  
30
    def get_form_kwargs(self):
31
        queryset = self.get_events()
32
        return {
33
            'data': self.request.GET,
34
            'limit': self.limit,
35
            'queryset': queryset,
36
        }
37

  
38
    def get_context_data(self, **kwargs):
39
        ctx = super().get_context_data(**kwargs)
40
        form = ctx['form']
41
        ctx['page'] = form.page
42
        ctx['date_hierarchy'] = form.date_hierarchy
43
        return ctx
44

  
45

  
46
class ContextDecoratedEvent:
47
    def __init__(self, context, event):
48
        self.context = context
49
        self.event = event
50

  
51
    def __getattr__(self, name):
52
        return getattr(self.event, name)
53

  
54
    @property
55
    def message(self):
56
        return self.event.message_in_context(self.context)
57

  
58

  
59
class ContextDecoratedPage:
60
    def __init__(self, context, page):
61
        self.context = context
62
        self.page = page
63

  
64
    def __getattr__(self, name):
65
        return getattr(self.page, name)
66

  
67
    def __iter__(self):
68
        return (ContextDecoratedEvent(self.context, event) for event in self.page)
69

  
70

  
71
class JournalViewWithContext(JournalView):
72
    context = None
73

  
74
    def get_events(self):
75
        qs = super().get_events()
76
        qs = qs.which_references(self.context)
77
        return qs
78

  
79
    def get_context_data(self, **kwargs):
80
        ctx = super().get_context_data(**kwargs)
81
        ctx['context'] = self.context
82
        ctx['page'] = ContextDecoratedPage(self.context, ctx['page'])
83
        return ctx
src/authentic2/settings.py
144 144
    'authentic2.attribute_aggregator',
145 145
    'authentic2.disco_service',
146 146
    'authentic2.manager',
147
    'authentic2.apps.journal',
147 148
    'authentic2',
148 149
    'django_rbac',
149 150
    'authentic2.a2_rbac',
tests/test_journal.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from datetime import datetime, timedelta
18
import random
19

  
20
import mock
21
import pytest
22

  
23
from django.contrib.auth import get_user_model
24
from django.core.management import call_command
25
from django.utils.timezone import make_aware, make_naive
26

  
27
from authentic2.apps.journal.forms import JournalForm
28
from authentic2.apps.journal.journal import Journal
29
from authentic2.apps.journal.models import EventTypeDefinition, EventType, Event, clean_registry
30
from authentic2.models import Service
31

  
32
User = get_user_model()
33

  
34

  
35
@pytest.fixture
36
def clean_event_types_definition_registry(request):
37
    '''Protect EventTypeDefinition registry'''
38
    with clean_registry():
39
        yield
40

  
41

  
42
@pytest.fixture
43
def some_event_types(clean_event_types_definition_registry):
44
    class UserRegistrationRequest(EventTypeDefinition):
45
        name = 'user.registration.request'
46
        label = 'registration request'
47

  
48
        @classmethod
49
        def record(cls, email):
50
            super().record(data={'email': email.lower()})
51

  
52
    class UserRegistration(EventTypeDefinition):
53
        name = 'user.registration'
54
        label = 'registration'
55

  
56
        @classmethod
57
        def record(cls, user, session, how):
58
            super().record(user=user, session=session, data={'how': how})
59

  
60
    class UserLogin(EventTypeDefinition):
61
        name = 'user.login'
62
        label = 'login'
63

  
64
        @classmethod
65
        def record(cls, user, session, how):
66
            super().record(user=user, session=session, data={'how': how})
67

  
68
    class UserLogout(EventTypeDefinition):
69
        name = 'user.logout'
70
        label = 'logout'
71

  
72
        @classmethod
73
        def record(cls, user, session):
74
            super().record(user=user, session=session)
75

  
76
    yield locals()
77

  
78

  
79
def test_models(db, django_assert_num_queries):
80
    service = Service.objects.create(name='service', slug='service')
81
    service2 = Service.objects.create(name='service2', slug='service2')
82
    user = User.objects.create(username='john.doe')
83
    sso_event = EventType.objects.create(name='sso')
84
    whatever_event = EventType.objects.create(name='whatever')
85
    ev1 = Event.objects.create(user=user, type=sso_event, data={'method': 'oidc'}, references=[service])
86
    events = [ev1]
87
    events.append(Event.objects.create(type=whatever_event, references=[user]))
88
    for i in range(10):
89
        events.append(Event.objects.create(type=whatever_event, references=[service if i % 2 else service2]))
90
    ev2 = events[6]
91

  
92
    # check extended queryset methods
93
    assert Event.objects.count() == 12
94
    assert Event.objects.which_references(user).count() == 2
95
    assert Event.objects.which_references(User).count() == 2
96
    assert Event.objects.filter(user=user).count() == 1
97
    assert Event.objects.which_references(service).count() == 6
98
    assert Event.objects.which_references(Service).count() == 11
99
    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]
102
    assert set(Event.objects.which_references(service)[0].references) == set([service])
103

  
104
    # verify type, user and service are prefetched
105
    with django_assert_num_queries(3):
106
        events = list(Event.objects.prefetch_references())
107
        assert len(events) == 12
108
        event = events[0]
109
        event.type.name
110
        assert event.user == user
111
        assert len(event.references) == 1
112
        assert event.references[0] == service
113

  
114
    # check foreign key constraints are not enforced, log should not change if an object is deleted
115
    Service.objects.all().delete()
116
    User.objects.all().delete()
117
    assert Event.objects.count() == 12
118
    assert Event.objects.filter(user_id=user.id).count() == 1
119
    assert Event.objects.which_references(user).count() == 2
120
    assert Event.objects.which_references(service).count() == 6
121
    assert list(Event.objects.all())
122

  
123

  
124
def test_references(db):
125
    user = User.objects.create(username='user')
126
    service = Service.objects.create(name='service', slug='service')
127

  
128
    event_type = EventType.objects.get_for_name('user.login')
129
    event = Event.objects.create(type=event_type, references=[user, service], user=user)
130
    event = Event.objects.get()
131
    assert list(event.get_typed_references(None, Service)) == [None, service]
132
    event = Event.objects.get()
133
    assert list(event.get_typed_references(User, None)) == [user, None]
134
    event = Event.objects.get()
135
    assert list(event.get_typed_references(Service, User)) == [None, None]
136
    assert list(event.get_typed_references(User, Service)) == [user, service]
137

  
138
    user.delete()
139
    service.delete()
140

  
141
    event = Event.objects.get()
142
    assert list(event.get_typed_references(None, Service)) == [None, None]
143
    event = Event.objects.get()
144
    assert list(event.get_typed_references(User, None)) == [None, None]
145
    event = Event.objects.get()
146
    assert list(event.get_typed_references(Service, User)) == [None, None]
147

  
148

  
149
def test_event_types(clean_event_types_definition_registry):
150
    class UserEventTypes(EventTypeDefinition):
151
        name = 'user'
152
        label = 'User events'
153

  
154
    class SSO(UserEventTypes):
155
        name = 'user.sso'
156
        label = 'Single sign On'
157

  
158
    # user is an abstract type
159
    assert EventTypeDefinition.get_for_name('user') is UserEventTypes
160
    assert EventTypeDefinition.get_for_name('user.sso') is SSO
161

  
162
    with pytest.raises(AssertionError, match='already registered'):
163
        class SSO2(UserEventTypes):
164
            name = 'user.sso'
165
            label = 'Single Sign On'
166

  
167

  
168
@pytest.mark.urls('tests.test_journal_app.urls')
169
def test_integration(clean_event_types_definition_registry, app_factory, db, settings):
170
    settings.INSTALLED_APPS = [
171
        'django.contrib.auth',
172
        'django.contrib.sessions',
173
        'authentic2.custom_user',
174
        'authentic2.apps.journal',
175
        'tests.test_journal_app',
176
    ]
177
    app = app_factory()
178

  
179
    # the whole test is in a transaction :/
180
    app.get('/login/john.doe/')
181

  
182
    assert Event.objects.count() == 1
183
    event = Event.objects.get()
184
    assert event.type.name == 'login'
185
    assert event.user.username == 'john.doe'
186
    assert event.session_id == app.session.session_key
187
    assert event.reference_ids is None
188
    assert event.data is None
189

  
190

  
191
@pytest.fixture
192
def random_events(db):
193
    count = 100
194
    from_date = make_aware(datetime(2000, 1, 1))
195
    to_date = make_aware(datetime(2010, 1, 1))
196
    duration = (to_date - from_date).total_seconds()
197
    events = []
198
    event_types = []
199
    for name in 'abcdef':
200
        event_types.append(EventType.objects.create(name=name))
201

  
202
    for i in range(count):
203
        events.append(
204
            Event(
205
                type=random.choice(event_types),
206
                timestamp=from_date + timedelta(seconds=random.uniform(0, duration)),
207
            )
208
        )
209
    Event.objects.bulk_create(events)
210
    return list(Event.objects.order_by('timestamp', 'id'))
211

  
212

  
213
def test_journal_form_date_hierarchy(random_events, rf):
214
    request = rf.get('/')
215
    form = JournalForm(data=request.GET)
216
    assert len(form.years) > 1  # 1 chance on 10**100 of false negative
217
    assert all(2000 <= year < 2010 for year in form.years)
218
    assert form.months == []
219
    assert form.days == []
220
    assert form.get_queryset().count() == 100
221

  
222
    year = random.choice(form.years)
223
    request = rf.get('/?year=%s' % year)
224
    form = JournalForm(data=request.GET)
225
    assert len(form.years) > 1
226
    assert all(2000 <= year < 2010 for year in form.years)
227
    assert len(form.months)
228
    assert all(1 <= month <= 12 for month in form.months)
229
    assert form.days == []
230
    assert form.get_queryset().count() == len(
231
        [
232
            # use make_naive() as filter(timestamp__year=..) convert value to local datetime
233
            # but event.timestamp only return UTC timezoned datetimes.
234
            event
235
            for event in random_events
236
            if make_naive(event.timestamp).year == year
237
        ]
238
    )
239

  
240
    month = random.choice(form.months)
241
    request = rf.get('/?year=%s&month=%s' % (year, month))
242
    form = JournalForm(data=request.GET)
243
    assert len(form.years) > 1
244
    assert all(2000 <= year < 2010 for year in form.years)
245
    assert len(form.months)
246
    assert all(1 <= month <= 12 for month in form.months)
247
    assert len(form.days)
248
    assert all(1 <= day <= 31 for day in form.days)
249
    assert form.get_queryset().count() == len(
250
        [
251
            # use make_naive() as filter(timestamp__year=..) convert value to local datetime
252
            # but event.timestamp only return UTC timezoned datetimes.
253
            event
254
            for event in random_events
255
            if make_naive(event.timestamp).year == year and make_naive(event.timestamp).month == month
256
        ]
257
    )
258

  
259
    day = random.choice(form.days)
260
    datetime(year, month, day)
261
    request = rf.get('/?year=%s&month=%s&day=%s' % (year, month, day))
262
    form = JournalForm(data=request.GET)
263
    assert len(form.years) > 1
264
    assert all(2000 <= year < 2010 for year in form.years)
265
    assert len(form.months) > 1
266
    assert all(1 <= month <= 12 for month in form.months)
267
    assert len(form.days)
268
    assert all(1 <= day <= 31 for day in form.days)
269
    assert form.get_queryset().count() == len(
270
        [
271
            event
272
            for event in random_events
273
            if event.timestamp.year == year and event.timestamp.month == month and event.timestamp.day == day
274
        ]
275
    )
276

  
277

  
278
def test_journal_form_pagination(random_events, rf):
279
    request = rf.get('/')
280
    page = JournalForm(data=request.GET).page
281
    assert not page.is_first_page
282
    assert page.is_last_page
283
    assert not page.next_page_url
284
    assert page.previous_page_url
285
    assert page.events == random_events[-page.limit:]
286

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

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

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

  
312
    event_after_the_first_page = random_events[page.limit]
313
    request = rf.get('/' + form.make_url('before_cursor', event_after_the_first_page.cursor))
314
    form = JournalForm(data=request.GET)
315
    page = form.page
316
    assert page.is_first_page
317
    assert not page.is_last_page
318
    assert page.next_page_url
319
    assert not page.previous_page_url
320
    assert page.events == random_events[: page.limit]
321

  
322
    # Test cursors out of queryset range
323
    request = rf.get('/?' + form.make_url('after_cursor', random_events[0].cursor))
324
    form = JournalForm(
325
        queryset=Event.objects.filter(
326
            timestamp__range=[random_events[1].timestamp, random_events[20].timestamp]
327
        ),
328
        data=request.GET,
329
    )
330
    page = form.page
331
    assert page.is_first_page
332
    assert page.is_last_page
333
    assert not page.previous_page_url
334
    assert not page.next_page_url
335
    assert page.events == random_events[1:21]
336

  
337
    request = rf.get('/' + form.make_url('before_cursor', random_events[21].cursor))
338
    page = JournalForm(
339
        queryset=Event.objects.filter(
340
            timestamp__range=[random_events[1].timestamp, random_events[20].timestamp]
341
        ),
342
        data=request.GET,
343
    ).page
344
    assert page.is_first_page
345
    assert page.is_last_page
346
    assert not page.previous_page_url
347
    assert not page.next_page_url
348
    assert page.events == random_events[1:21]
349

  
350

  
351
@pytest.fixture
352
def user_events(db, some_event_types):
353
    user = User.objects.create(username='john.doe', email='john.doe@example.com')
354

  
355
    journal = Journal(user=user)
356
    count = 100
357

  
358
    journal.record('user.registration.request', email=user.email)
359
    journal.record('user.registration', how='fc')
360
    journal.record('user.logout')
361

  
362
    for i in range(count):
363
        journal.record('user.login', how='fc')
364
        journal.record('user.logout')
365

  
366
    return list(Event.objects.order_by('timestamp', 'id'))
367

  
368

  
369
def test_journal_form_search(user_events, rf):
370
    request = rf.get('/')
371
    form = JournalForm(data=request.GET)
372
    assert form.get_queryset().count() == len(user_events)
373

  
374
    request = rf.get('/', data={'search': 'email:jane.doe@example.com'})
375
    form = JournalForm(data=request.GET)
376
    assert form.get_queryset().count() == 0
377

  
378
    request = rf.get('/', data={'search': 'email:john.doe@example.com event:registration'})
379
    form = JournalForm(data=request.GET)
380
    assert form.get_queryset().count() == 1
381

  
382
    User.objects.update(username='john doe')
383

  
384
    request = rf.get('/', data={'search': 'username:"john doe" event:registration'})
385
    form = JournalForm(data=request.GET)
386
    assert form.get_queryset().count() == 1
387

  
388
    # unhandled lexems make the queryset empty
389
    request = rf.get('/', data={'search': 'john doe event:registration'})
390
    form = JournalForm(data=request.GET)
391
    assert form.get_queryset().count() == 0
392

  
393
    # unhandled prefix make unhandled lexems
394
    request = rf.get('/', data={'search': 'test:john'})
395
    form = JournalForm(data=request.GET)
396
    assert form.get_queryset().count() == 0
397

  
398

  
399
def test_cleanup(user_events, some_event_types, freezer, monkeypatch):
400
    monkeypatch.setattr(some_event_types['UserRegistration'], 'retention_days', 0)
401

  
402
    count = Event.objects.count()
403
    freezer.move_to(timedelta(days=365 * 2 - 1))
404
    call_command('cleanupauthentic')
405
    assert Event.objects.count() == count
406
    freezer.move_to(timedelta(days=2))
407
    call_command('cleanupauthentic')
408
    assert Event.objects.count() == 1
409

  
410

  
411
def test_record_exception_handling(db, some_event_types, caplog):
412
    journal = Journal()
413
    journal.record('user.registration.request', email='john.doe@example.com')
414
    assert len(caplog.records) == 0
415
    with mock.patch.object(some_event_types['UserRegistrationRequest'], 'record', side_effect=Exception('boum')):
416
        journal.record('user.registration.request', email='john.doe@example.com')
417
    assert len(caplog.records) == 1
418
    assert caplog.records[0].levelname == 'ERROR'
419
    assert caplog.records[0].message == 'failure to record event "user.registration.request"'
420

  
421

  
422
def test_message_in_context_exception_handling(db, some_event_types, caplog):
423
    user = User.objects.create(username='john.doe', email='john.doe@example.com')
424
    journal = Journal()
425
    journal.record('user.login', user=user, how='password')
426
    event = Event.objects.get()
427

  
428
    event.message
429
    assert not(caplog.records)
430

  
431
    caplog.clear()
432
    with mock.patch.object(some_event_types['UserLogin'], 'get_message', side_effect=Exception('boum')):
433
        event.message
434
    assert len(caplog.records) == 1
435
    assert caplog.records[0].levelname == 'ERROR'
436
    assert caplog.records[0].message == 'could not render message of event type "user.login"'
437

  
438
    caplog.clear()
439
    with mock.patch.object(some_event_types['UserLogin'], 'get_message', side_effect=Exception('boum')):
440
        event.message_in_context(None)
441
    assert len(caplog.records) == 1
442
    assert caplog.records[0].levelname == 'ERROR'
443
    assert caplog.records[0].message == 'could not render message of event type "user.login"'
tests/test_journal_app/journal_event_types.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17

  
18
from authentic2.apps.journal.models import EventTypeDefinition
19

  
20

  
21
class Login(EventTypeDefinition):
22
    name = 'login'
23
    label = 'Login'
24

  
25
    @classmethod
26
    def record(cls, user=None, session=None):
27
        super().record(user=user, session=session)
tests/test_journal_app/urls.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.conf.urls import url
18

  
19
from . import views
20

  
21
urlpatterns = [
22
    url('^login/(?P<name>[^/]+)/', views.login_view),
23
]
tests/test_journal_app/views.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17

  
18
from django.contrib.auth import get_user_model, login, authenticate
19
from django.http import HttpResponse
20

  
21
from authentic2.apps.journal.journal import journal
22

  
23
User = get_user_model()
24

  
25

  
26
def login_view(request, name):
27
    user = User.objects.create(username=name)
28
    user.set_password('coin')
29
    user.save()
30
    user = authenticate(username=name, password='coin')
31
    login(request, user)
32
    journal.record('login', user=user, session=request.session)
33
    return HttpResponse('logged in', content_type='text/plain')
tox.ini
130 130
    authentic2_auth_saml
131 131
    authentic2_idp_cas
132 132
    authentic2_idp_oidc
133
    authentic2_journal
134 133
    authentic2_provisionning_ldap
135 134
    django_rbac
136 135
branch = True
137
-