0001-misc-add-journal-application-47155.patch
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 |
- |