diff --git a/tests/backoffice_pages/test_audit.py b/tests/backoffice_pages/test_audit.py new file mode 100644 index 000000000..3dd83c900 --- /dev/null +++ b/tests/backoffice_pages/test_audit.py @@ -0,0 +1,166 @@ +import datetime + +import pytest + +from wcs.audit import Audit +from wcs.formdef import FormDef +from wcs.qommon import audit +from wcs.qommon.http_request import HTTPRequest + +from ..utilities import clean_temporary_pub, create_temporary_pub, get_app, login +from .test_all import create_superuser + + +@pytest.fixture +def superuser(pub): + return create_superuser(pub) + + +@pytest.fixture +def pub(request, emails): + pub = create_temporary_pub() + + req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'}) + pub.set_app_dir(req) + pub.cfg['identification'] = {'methods': ['password']} + pub.cfg['language'] = {'language': 'en'} + pub.write_cfg() + return pub + + +def teardown_module(module): + clean_temporary_pub() + + +def test_formdata_audit(pub, superuser): + FormDef.wipe() + formdef = FormDef() + formdef.name = 'form title' + formdef.store() + + formdef.data_class().wipe() + formdata = formdef.data_class()() + formdata.just_created() + formdata.store() + + app = login(get_app(pub)) + app.get(formdef.get_url(backoffice=True)) + app.get(formdata.get_backoffice_url()) + + assert Audit.count() == 2 + audit1, audit2 = Audit.select(order_by='id') + assert audit1.action == 'listing' + assert audit2.action == 'view' + assert audit1.object_type == audit2.object_type == 'formdef' + assert audit1.object_id == audit2.object_id == str(formdef.id) + assert audit1.data_id is None + assert audit2.data_id == formdata.id + + assert audit2.frozen['user_email'] == superuser.email + assert audit2.frozen['object_slug'] == formdef.slug + + +def test_audit_journal(pub, superuser): + Audit.wipe() + FormDef.wipe() + + formdef = FormDef() + formdef.name = 'form title' + formdef.store() + + formdef.data_class().wipe() + formdata = formdef.data_class()() + formdata.just_created() + formdata.store() + + formdef2 = FormDef() + formdef2.name = 'form title 2' + formdef2.store() + formdef2.data_class().wipe() + formdata2 = formdef2.data_class()() + formdata2.just_created() + formdata2.store() + + app = login(get_app(pub)) + resp = app.get('/backoffice/journal/') # visit empty + assert resp.pyquery('tbody tr').length == 0 + + for i in range(5): + audit('listing', obj=formdef, user_id=superuser.id) + for i in range(50): + audit('view', obj=formdata, user_id=superuser.id) + + # create audit object in the past + audit_obj = Audit.select(order_by='-id')[0] + audit_obj.id = None + audit_obj.timestamp = audit_obj.timestamp - datetime.timedelta(days=40) + audit_obj.store() + + # additional audit events + audit('export.csv', obj=formdef, user_id=superuser.id) + audit('export.csv', obj=formdef2, user_id=superuser.id) + audit('download file', obj=formdata2, user_id=superuser.id, extra_label='file.png') + + resp = app.get('/backoffice/studio/') + resp = resp.click('Audit Journal') + assert resp.pyquery('.journal-table--user:first').text() == 'admin' + assert resp.pyquery('tbody tr').length == 10 + resp = resp.click('Next page') + assert resp.pyquery('tbody tr').length == 10 + resp = resp.click('Previous page') + assert resp.pyquery('tbody tr').length == 10 + resp = resp.click('First page') + assert resp.pyquery('tbody tr').length == 10 + assert resp.pyquery('.button.first-page.disabled') + + resp = resp.click('Last page') + assert resp.pyquery('tbody tr').length == 10 + assert resp.pyquery('.button.last-page.disabled') + + resp.form['date'] = audit_obj.timestamp.strftime('%Y-%m-%d') + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 1 + + resp.form['date'] = '' + resp.form['action'] = 'listing' + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 5 + + resp.form['user_id'].force_value('12') + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 0 + + resp.form['user_id'].force_value(superuser.id) + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 5 + assert resp.form['user_id'].value == str(superuser.id) + assert resp.form['user_id'].options[-1] == (str(superuser.id), True, 'admin') + + resp.form['action'] = 'export.csv' + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 2 + + assert resp.pyquery('[data-widget-name="object_id"]').attr.style == 'display: none' + resp.form['object'] = f'formdef:{formdef2.id}' + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 1 + assert resp.pyquery('[data-widget-name="object_id"]').attr.style != 'display: none' + + resp.form['action'] = '' + resp.form['object_id'] = formdata2.id + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 1 + + resp.form['object_id'] = 'XXX' + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 0 + + # check journal is still displayed correctly after formdef removal + resp.form['object_id'] = '' + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 2 + assert resp.pyquery('.journal-table--description')[-1].text == 'CSV Export - form title 2' + formdef2.remove_self() + resp = resp.form.submit('submit') + assert resp.pyquery('tbody tr').length == 2 + assert resp.pyquery('.journal-table--description')[-1].text == 'CSV Export - form title 2' diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 000000000..937d610f0 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,48 @@ +import pytest +from django.utils.timezone import now + +from wcs.audit import Audit +from wcs.formdef import FormDef +from wcs.qommon import audit +from wcs.qommon.http_request import HTTPRequest + +from .utilities import clean_temporary_pub, create_temporary_pub + + +@pytest.fixture +def pub(request): + pub = create_temporary_pub() + req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'}) + pub.set_app_dir(req) + pub.load_site_options() + return pub + + +def teardown_module(module): + clean_temporary_pub() + + +def test_audit_clean_job(pub, freezer): + Audit.wipe() + FormDef.wipe() + pub.user_class.wipe() + + user = pub.user_class() + user.name = 'Test' + user.store() + + formdef = FormDef() + formdef.name = 'form title' + formdef.store() + + current_timestamp = now() + freezer.move_to('2018-12-01T00:00:00') + audit('listing', obj=formdef, user=user.id) + audit('listing', obj=formdef, user=user.id) + + freezer.move_to(current_timestamp) + audit('listing', obj=formdef, user=user.id) + + assert Audit.count() == 3 + Audit.clean() + assert Audit.count() == 1 diff --git a/tests/utilities.py b/tests/utilities.py index 1ebbff02e..58026058a 100644 --- a/tests/utilities.py +++ b/tests/utilities.py @@ -127,6 +127,7 @@ def create_temporary_pub(pickle_mode=False, lazy_mode=False): pub.cfg['postgresql'] = {'database': known_elements.sql_db_name, 'user': os.environ['USER']} pub.loggederror_class.wipe() sql.WorkflowTrace.wipe() + sql.Audit.wipe() pub.write_cfg() return pub @@ -163,6 +164,7 @@ def create_temporary_pub(pickle_mode=False, lazy_mode=False): sql.do_custom_views_table() sql.do_snapshots_table() sql.do_loggederrors_table() + sql.Audit.do_table() sql.do_meta_table() TestDef.do_table() sql.WorkflowTrace.do_table() diff --git a/wcs/audit.py b/wcs/audit.py new file mode 100644 index 000000000..7aa0be833 --- /dev/null +++ b/wcs/audit.py @@ -0,0 +1,98 @@ +# w.c.s. - web application for online forms +# Copyright (C) 2005-2022 Entr'ouvert +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +import datetime + +from django.utils.timezone import now +from quixote import get_publisher, get_request + +from wcs import sql +from wcs.qommon import _ + + +class Audit(sql.Audit): + id = None + timestamp = None + action = None + url = None + user_id = None + object_type = None # (formdef, carddef, etc.) + object_id = None + data_id = None # (for formdata and carddata) + extra_data = None + + @classmethod + def record(cls, action, obj=None, user_id=None, **kwargs): + audit = cls() + audit.action = action + audit.timestamp = now() + request = get_request() + user = None + if user_id: + audit.user_id = user_id + user = get_publisher().user_class.get(audit.user_id, ignore_errors=True) + elif request: + user = request.get_user() + if user and hasattr(user, 'id'): + audit.user_id = user.id + if request: + audit.url = request.get_path_query() + if obj: + if hasattr(obj, '_formdef'): # formdata or carddata + audit.data_id = obj.id + obj = obj._formdef + audit.object_type = obj.xml_root_node + audit.object_id = obj.id + audit.extra_data = kwargs + audit.frozen = { + 'user_email': getattr(user, 'email', None), + 'user_full_name': getattr(user, 'display_name', None), + 'user_nameid': getattr(user, 'nameid', None), + 'object_slug': getattr(obj, 'slug', None), + 'object_name': getattr(obj, 'name', None), + } + audit.store() + + @classmethod + def get_action_labels(cls): + return { + 'listing': _('Listing'), + 'export.csv': _('CSV Export'), + 'export.ods': _('ODS Export'), + 'download file': _('Download of attached file'), + 'download files': _('Download of attached files (bundle)'), + 'view': _('View Data'), + } + + def get_action_description(self): + action_label = self.get_action_labels().get(self.action, self.action) + obj_name = self.frozen.get('object_name') + if self.object_type and self.object_id: + obj_class = get_publisher().get_object_class(self.object_type) + obj = obj_class.get(self.object_id, ignore_errors=True) + if obj: + obj_name = obj.name + parts = [str(action_label), obj_name] + if self.data_id: + parts.append(str(self.data_id)) + if self.extra_data and self.extra_data.get('extra_label'): + parts.append(self.extra_data.get('extra_label')) + return ' - '.join(parts) + + @classmethod + def clean(cls, **kwargs): + audit_retention_days = get_publisher().get_site_option('audit-retention-days') or 730 + Audit.wipe(clause=[sql.Less('timestamp', now() - datetime.timedelta(days=int(audit_retention_days)))]) diff --git a/wcs/backoffice/journal.py b/wcs/backoffice/journal.py new file mode 100644 index 000000000..ecc07362a --- /dev/null +++ b/wcs/backoffice/journal.py @@ -0,0 +1,154 @@ +# w.c.s. - web application for online forms +# Copyright (C) 2005-2022 Entr'ouvert +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +import datetime + +from quixote import get_publisher, get_request, get_response +from quixote.directory import Directory + +from wcs.carddef import CardDef +from wcs.formdef import FormDef +from wcs.qommon import _, template +from wcs.qommon.backoffice.menu import html_top +from wcs.qommon.form import DateWidget, Form, OptGroup, SingleSelectWidget, StringWidget +from wcs.qommon.misc import get_as_datetime +from wcs.qommon.storage import Equal, Greater, GreaterOrEqual, Less, Nothing + + +class JournalDirectory(Directory): + _q_exports = [''] + + def html_top(self, title): + return html_top('journal', title) + + def _q_traverse(self, path): + get_response().breadcrumb.append(('journal/', _('Audit Journal'))) + return super()._q_traverse(path) + + def _q_index(self): + from wcs.audit import Audit + + self.html_top(_('Audit Journal')) + context = { + 'has_sidebar': True, + 'html_form': self.get_filter_form(), + } + criterias = [] + querystring_parts = [] + order_by = '-id' + if get_request().form.get('date'): + dt = get_as_datetime(get_request().form.get('date')) + dtm = dt + datetime.timedelta(days=1) + criterias.append(Less('timestamp', dtm)) + criterias.append(GreaterOrEqual('timestamp', dt)) + querystring_parts.append('date=%s' % get_request().form.get('date')) + + if get_request().form.get('action'): + criterias.append(Equal('action', get_request().form.get('action'))) + querystring_parts.append('action=%s' % get_request().form.get('action')) + + if get_request().form.get('user_id'): + criterias.append(Equal('user_id', get_request().form.get('user_id'))) + querystring_parts.append('user_id=%s' % get_request().form.get('user_id')) + + if get_request().form.get('object'): + object_type, object_id = get_request().form.get('object').split(':') + criterias.append(Equal('object_type', object_type)) + criterias.append(Equal('object_id', object_id)) + querystring_parts.append('object=%s' % get_request().form.get('object')) + + formdata_id = get_request().form.get('object_id') + if formdata_id: + querystring_parts.append('object_id=%s' % formdata_id) + try: + formdata_id = int(formdata_id) + except ValueError: + criterias.append(Nothing()) + else: + criterias.append(Equal('data_id', formdata_id)) + + if get_request().form.get('max'): + criterias.append(Less('id', get_request().form.get('max'))) + elif get_request().form.get('min'): + criterias.append(Greater('id', get_request().form.get('min'))) + order_by = 'id' + context['lines'] = lines = Audit.select(criterias, order_by=order_by, limit=10) + first_id = Audit.get_first_id() + if order_by == 'id': + lines.reverse() + if len(lines) < 10 and get_request().form.get('min'): + get_request().form['min'] = None + return self._q_index() + if lines: + context['last_row_id'] = max(x.id for x in lines) + context['first_row_id'] = min(x.id for x in lines) + elif get_request().form.get('min'): + get_request().form['min'] = None + return self._q_index() + + if first_id in [x.id for x in lines]: + # on latest page + context['no_next'] = True + + if not get_request().form.get('min') and not get_request().form.get('max'): + context['no_prev'] = True + + context['latest_page_id'] = first_id + 10 + context['extra_qs'] = '&'.join(querystring_parts) + return template.QommonTemplateResponse( + templates=['wcs/backoffice/journal.html'], context=context, is_django_native=True + ) + + def is_accessible(self, user): + return user.can_go_in_admin() + + def get_filter_form(self): + from wcs.audit import Audit + + get_response().add_javascript(['select2.js']) + form = Form(method='get', action='.', id='journal-filter') + form.add(DateWidget, 'date', title=_('Date'), date_in_the_past=True, date_can_be_today=True) + + user_options = [(None, '', '')] + if get_request().form.get('user_id'): + user = get_publisher().user_class.get(get_request().form.get('user_id'), ignore_errors=True) + if user: + user_options.append((user.id, str(user), user.id)) + form.add( + SingleSelectWidget, 'user_id', title=_('User'), options=user_options, class_='user-selection' + ) + + formdefs = FormDef.select(order_by='name', lightweight=True, ignore_errors=True) + carddefs = CardDef.select(order_by='name', lightweight=True, ignore_errors=True) + object_options = [(None, '', '')] + if formdefs and carddefs: + object_options.append(OptGroup(_('Forms'))) + object_options.extend([(f'formdef:{x.id}', x.name, f'formdef:{x.id}') for x in formdefs]) + if formdefs and carddefs: + object_options.append(OptGroup(_('Card Models'))) + object_options.extend([(f'carddef:{x.id}', x.name, f'carddef:{x.id}') for x in carddefs]) + form.add(SingleSelectWidget, 'object', title=_('Form/Card'), options=object_options) + widget = form.add(StringWidget, 'object_id', title=_('Form/Card Identifier')) + if not form.get_widget('object').parse(): + widget.is_hidden = True + form.add( + SingleSelectWidget, + 'action', + title=_('Action'), + options=[('', '', '')] + [(x[0], x[1], x[0]) for x in Audit.get_action_labels().items()], + ) + form.add_submit('submit', _('Search')) + return form diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py index 79bacb97c..ee4233aa6 100644 --- a/wcs/backoffice/management.py +++ b/wcs/backoffice/management.py @@ -46,7 +46,7 @@ from wcs.roles import logged_users_role from wcs.variables import LazyFieldVar, LazyList from wcs.workflows import WorkflowStatusItem, item_classes, template_on_formdata -from ..qommon import _, errors, ezt, force_str, get_cfg, misc, ngettext, ods, pgettext_lazy, template +from ..qommon import _, audit, errors, ezt, force_str, get_cfg, misc, ngettext, ods, pgettext_lazy, template from ..qommon.afterjobs import AfterJob from ..qommon.backoffice.listing import pagination_links from ..qommon.backoffice.menu import html_top @@ -2200,6 +2200,9 @@ class FormPage(FormdefDirectoryBase): multi_form.widgets.append(HtmlWidget(table)) if not multi_actions: multi_form.widgets.append(HtmlWidget('
')) + + audit('listing', obj=self.formdef, refresh=bool(get_request().form.get('ajax') == 'true')) + if get_request().form.get('ajax') == 'true': get_request().ignore_session = True get_response().filter = {'raw': True} @@ -2313,6 +2316,7 @@ class FormPage(FormdefDirectoryBase): get_request().form = parse_query(form.get_widget('query_string').parse() or '', 'utf-8') get_request().form['skip_header_line'] = not (form.get_widget('include_header_line').parse()) file_format = form.get_widget('format').parse() + audit('export.%s' % file_format, obj=self.formdef) if file_format == 'csv': return self.csv() elif file_format == 'json': @@ -3354,6 +3358,7 @@ class FormBackOfficeStatusPage(FormStatusPage): if isinstance(subvalue_elem, PicklableUpload): add_zip_file(subvalue_elem, zip_file) + audit('download files', obj=formdata) response = get_response() response.set_content_type('application/zip') response.set_header( diff --git a/wcs/backoffice/root.py b/wcs/backoffice/root.py index 19f06f319..6a06b2107 100644 --- a/wcs/backoffice/root.py +++ b/wcs/backoffice/root.py @@ -34,13 +34,14 @@ from ..qommon.backoffice.menu import html_top from .cards import CardsDirectory from .data_management import DataManagementDirectory from .i18n import I18nDirectory +from .journal import JournalDirectory from .management import ManagementDirectory from .studio import StudioDirectory from .submission import SubmissionDirectory class RootDirectory(BackofficeRootDirectory): - _q_exports = ['', 'pending', 'statistics', ('menu.json', 'menu_json'), 'processing'] + _q_exports = ['', 'pending', 'statistics', ('menu.json', 'menu_json'), 'processing', 'journal'] forms = wcs.admin.forms.FormsDirectory() roles = wcs.admin.roles.RolesDirectory() @@ -48,6 +49,7 @@ class RootDirectory(BackofficeRootDirectory): users = wcs.admin.users.UsersDirectory() workflows = wcs.admin.workflows.WorkflowsDirectory() management = ManagementDirectory() + journal = JournalDirectory() studio = StudioDirectory() cards = CardsDirectory() data = DataManagementDirectory() diff --git a/wcs/backoffice/studio.py b/wcs/backoffice/studio.py index 81c33bd98..f7524c223 100644 --- a/wcs/backoffice/studio.py +++ b/wcs/backoffice/studio.py @@ -120,6 +120,8 @@ class StudioDirectory(Directory): object_types += [CardDef] if backoffice_root.is_accessible('i18n') and get_publisher().has_i18n_enabled(): extra_links.append(('../i18n/', pgettext('studio', 'Multilinguism'))) + if backoffice_root.is_accessible('journal'): + extra_links.append(('../journal/', pgettext('studio', 'Audit Journal'))) user = get_request().user context = { diff --git a/wcs/forms/common.py b/wcs/forms/common.py index d77144ad3..24ffea75e 100644 --- a/wcs/forms/common.py +++ b/wcs/forms/common.py @@ -34,7 +34,7 @@ from wcs.qommon.admin.texts import TextsDirectory from wcs.wf.editable import EditableWorkflowStatusItem from wcs.workflows import RedisplayFormException -from ..qommon import _, errors, misc, template +from ..qommon import _, audit, errors, misc, template class FileDirectory(Directory): @@ -80,6 +80,10 @@ class FileDirectory(Directory): redirect_url = sign_url_auto_orig(redirect_url) return redirect(redirect_url) + if not self.thumbnails: + # do not log access to thumbnails as they will already be accounted for as + # a view of the formdata/carddata containing them. + audit('download file', obj=self.formdata, extra_label=component) return self.serve_file(file, thumbnail=self.thumbnails) @classmethod @@ -665,6 +669,7 @@ class FormStatusPage(Directory, FormTemplateMixin): return response get_response().add_javascript(['jquery.js', 'qommon.forms.js']) + audit('view', obj=self.filled) self.html_top('%s - %s' % (self.formdef.name, self.filled.id)) r = TemplateIO(html=True) r += get_session().display_message() @@ -776,10 +781,17 @@ class FormStatusPage(Directory, FormTemplateMixin): if not hasattr(field_data, 'file_digest'): continue if field_data.file_digest() == file_digest: - return FileDirectory.serve_file( - field_data, - thumbnail=bool(get_request().form.get('thumbnail') and field_data.can_thumbnail()), - ) + thumbnail = bool(get_request().form.get('thumbnail') and field_data.can_thumbnail()) + if not thumbnail: + # do not log access to thumbnails as they will already be accounted for as + # a view of the formdata/carddata containing them. + audit( + 'download file', + obj=self.filled, + extra_label=str(field_data), + file_digest=file_digest, + ) + return FileDirectory.serve_file(field_data, thumbnail=thumbnail) elif get_request().form and get_request().form.get('f'): try: fn = get_request().form['f'] diff --git a/wcs/publisher.py b/wcs/publisher.py index ca9f31ca9..0c507f254 100644 --- a/wcs/publisher.py +++ b/wcs/publisher.py @@ -153,6 +153,12 @@ class WcsPublisher(QommonPublisher): cls.register_cronjob( CronJob(cls.clean_deleted_users, name='clean_deleted_users', hours=[3], minutes=[0]) ) + + # once a day clean old audit entries + from .audit import Audit + + cls.register_cronjob(CronJob(Audit.clean, name='clean_audit', hours=[3], minutes=[0])) + # other jobs data_sources.register_cronjob() formdef.register_cronjobs() @@ -399,6 +405,7 @@ class WcsPublisher(QommonPublisher): sql.do_loggederrors_table() sql.do_tokens_table() sql.WorkflowTrace.do_table() + sql.Audit.do_table() sql.do_meta_table() from .carddef import CardDef from .formdef import FormDef @@ -485,6 +492,33 @@ class WcsPublisher(QommonPublisher): pass return logged_exception + def get_object_class(self, object_type): + from wcs.blocks import BlockDef + from wcs.carddef import CardDef + from wcs.categories import BlockCategory, CardDefCategory, Category, WorkflowCategory + from wcs.data_sources import NamedDataSource + from wcs.formdef import FormDef + from wcs.mail_templates import MailTemplate + from wcs.workflows import Workflow + from wcs.wscalls import NamedWsCall + + for klass in ( + BlockDef, + CardDef, + NamedDataSource, + FormDef, + Workflow, + NamedWsCall, + MailTemplate, + Category, + CardDefCategory, + WorkflowCategory, + BlockCategory, + ): + if klass.xml_root_node == object_type: + return klass + raise KeyError('no class for object type: %s' % object_type) + def apply_global_action_timeouts(self, **kwargs): from wcs.workflows import Workflow, WorkflowGlobalActionTimeoutTrigger diff --git a/wcs/qommon/__init__.py b/wcs/qommon/__init__.py index 8480c0874..8df7646c8 100644 --- a/wcs/qommon/__init__.py +++ b/wcs/qommon/__init__.py @@ -38,6 +38,12 @@ force_str = force_text PICKLE_KWARGS = {'encoding': 'bytes', 'fix_imports': True} +def audit(action, **kwargs): + from wcs.audit import Audit + + Audit.record(action, **kwargs) + + def gettext(message): pub = get_publisher() if pub is None: diff --git a/wcs/qommon/static/css/dc2/admin.scss b/wcs/qommon/static/css/dc2/admin.scss index e22ab2695..0ec6b864c 100644 --- a/wcs/qommon/static/css/dc2/admin.scss +++ b/wcs/qommon/static/css/dc2/admin.scss @@ -922,14 +922,22 @@ div.bo-block div#page-links { padding: 1ex 1.5ex; } -#page-links .previous-page:before { +.first-page:before { + content: "≪"; +} + +.previous-page:before { content: "<"; } -#page-links .next-page:before { +.next-page:before { content: ">"; } +.last-page:before { + content: "≫"; +} + div#page-links a { padding: 1ex 1.5ex; border: none; @@ -2605,3 +2613,7 @@ div#main-content > h3.field-edit--subtitle { max-height: 10em; overflow-y: auto; } + +.journal-table--datetime { + width: 10em; +} diff --git a/wcs/qommon/static/js/qommon.admin.js b/wcs/qommon/static/js/qommon.admin.js index 41bfcfb36..a0502a644 100644 --- a/wcs/qommon/static/js/qommon.admin.js +++ b/wcs/qommon/static/js/qommon.admin.js @@ -140,6 +140,14 @@ $(function() { }); }); + $('#journal-filter #form_object').on('change', function() { + if (this.value) { + $('[data-widget-name="object_id"]').show(); + } else { + $('[data-widget-name="object_id"]').hide(); + } + }); + /* keep title/slug in sync */ $('body').delegate('input[data-slug-sync]', 'input change paste', function() { @@ -185,6 +193,7 @@ $(function() { url: '/api/users/' }, placeholder: '-', + allowClear: true, templateResult: function (state) { if (!state.description) { return state.text; @@ -197,9 +206,9 @@ $(function() { return $template_string; } } - if ($('div.submit-user-selection').length) { - $('div.submit-user-selection select').select2(user_select2_options); - $('div.submit-user-selection select').on('select2:open', function (e) { + if ($('div.submit-user-selection, select.user-selection').length) { + $('div.submit-user-selection select, select.user-selection').select2(user_select2_options); + $('div.submit-user-selection select, select.user-selection').on('select2:open', function (e) { var available_height = $(window).height() - $(this).offset().top; $('ul.select2-results__options').css('max-height', (available_height - 100) + 'px'); }); @@ -341,6 +350,27 @@ $(function() { }); $('[type=radio][name=display_mode]:checked').trigger('change'); + function prepate_journal_links() { + $('#journal-page-links a').on('click', function() { + var url = $(this).attr('href'); + $.ajax({url: url, dataType: 'html', success: function(html) { + var $html = $(html); + var $table = $html.find('#journal-table'); + var $page_links = $html.find('#journal-page-links'); + if ($table.length && $page_links.length) { + $('#journal-table').replaceWith($table); + $('#journal-page-links').replaceWith($page_links); + prepate_journal_links(); + if (window.history) { + window.history.replaceState(null, null, url); + } + } + }}); + return false; + }); + } + prepate_journal_links(); + // IE doesn't accept periods or dashes in the window name, but the element IDs // we use to generate popup window names may contain them, therefore we map them // to allowed characters in a reversible way so that we can locate the correct diff --git a/wcs/snapshots.py b/wcs/snapshots.py index 090b05d19..8f174c13e 100644 --- a/wcs/snapshots.py +++ b/wcs/snapshots.py @@ -196,35 +196,11 @@ class Snapshot: return instances def get_object_class(self): - return Snapshot.get_class(self.object_type) + return get_publisher().get_object_class(self.object_type) @classmethod def get_class(cls, object_type): - from wcs.blocks import BlockDef - from wcs.carddef import CardDef - from wcs.categories import BlockCategory, CardDefCategory, Category, WorkflowCategory - from wcs.data_sources import NamedDataSource - from wcs.formdef import FormDef - from wcs.mail_templates import MailTemplate - from wcs.workflows import Workflow - from wcs.wscalls import NamedWsCall - - for klass in ( - BlockDef, - CardDef, - NamedDataSource, - FormDef, - Workflow, - NamedWsCall, - MailTemplate, - Category, - CardDefCategory, - WorkflowCategory, - BlockCategory, - ): - if klass.xml_root_node == object_type: - return klass - raise KeyError('no class for object type: %s' % object_type) + return get_publisher().get_object_class(object_type) def get_serialization(self, indented=True): # there is a complete serialization diff --git a/wcs/sql.py b/wcs/sql.py index cd1c9bfa7..46a065ee2 100644 --- a/wcs/sql.py +++ b/wcs/sql.py @@ -4571,6 +4571,117 @@ class WorkflowTrace(SqlMixin): formdata.store() +class Audit(SqlMixin): + _table_name = 'audit' + _table_static_fields = [ + ('id', 'bigserial'), + ('timestamp', 'timestamptz'), + ('action', 'varchar'), + ('url', 'varchar'), + ('user_id', 'varchar'), + ('object_type', 'varchar'), + ('object_id', 'varchar'), + ('data_id', 'int'), + ('extra_data', 'jsonb'), + ('frozen', 'jsonb'), # plain copy of user email, object name and slug + ] + id = None + + @classmethod + @guard_postgres + def do_table(cls): + conn, cur = get_connection_and_cursor() + table_name = cls._table_name + + cur.execute( + '''SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = %s''', + (table_name,), + ) + if cur.fetchone()[0] == 0: + cur.execute( + '''CREATE TABLE %s (id BIGSERIAL, + timestamp TIMESTAMP WITH TIME ZONE, + action VARCHAR, + url VARCHAR, + user_id VARCHAR, + user_email VARCHAR, + object_type VARCHAR, + object_id VARCHAR, + data_id INTEGER, + extra_data JSONB, + frozen JSONB + )''' + % table_name + ) + cur.execute( + '''SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = %s''', + (table_name,), + ) + existing_fields = {x[0] for x in cur.fetchall()} + + needed_fields = {x[0] for x in Audit._table_static_fields} + + # delete obsolete fields + for field in existing_fields - needed_fields: + cur.execute('''ALTER TABLE %s DROP COLUMN %s''' % (table_name, field)) + + cur.execute('CREATE INDEX IF NOT EXISTS audit_id_idx ON audit USING btree (id)') + + conn.commit() + cur.close() + + @guard_postgres + def store(self): + if self.id: + # do not allow updates + raise AssertionError() + + sql_dict = {x: getattr(self, x) for x, y in self._table_static_fields} + + conn, cur = get_connection_and_cursor() + column_names = [x for x in sql_dict.keys() if x != 'id'] + sql_statement = '''INSERT INTO %s (%s) + VALUES (%s) + RETURNING id''' % ( + self._table_name, + ', '.join(column_names), + ', '.join(['%%(%s)s' % x for x in column_names]), + ) + cur.execute(sql_statement, sql_dict) + self.id = cur.fetchone()[0] + conn.commit() + cur.close() + + @classmethod + def _row2ob(cls, row, **kwargs): + o = cls() + for field, value in zip(cls._table_static_fields, tuple(row)): + setattr(o, field[0], value) + return o + + @classmethod + def get_data_fields(cls): + return [] + + @classmethod + @guard_postgres + def get_first_id(cls): + conn, cur = get_connection_and_cursor() + sql_statement = 'SELECT id FROM audit ORDER BY id LIMIT 1' + cur.execute(sql_statement) + try: + first_id = cur.fetchall()[0][0] + except IndexError: + first_id = 0 + conn.commit() + cur.close() + return first_id + + class classproperty: def __init__(self, f): self.f = f @@ -4949,7 +5060,7 @@ def get_period_total( # latest migration, number + description (description is not used # programmaticaly but will make sure git conflicts if two migrations are # separately added with the same number) -SQL_LEVEL = (77, 'use token table for nonces') +SQL_LEVEL = (78, 'add audit table') def migrate_global_views(conn, cur): @@ -5138,6 +5249,9 @@ def migrate(): # 75: migrate to dedicated workflow traces table # 76: add index to workflow traces table WorkflowTrace.do_table() + if sql_level < 78: + # 78: add audit table + Audit.do_table() if sql_level < 52: # 2: introduction of formdef_id in views # 5: add concerned_roles_array, is_at_endpoint and fts to views diff --git a/wcs/templates/wcs/backoffice/journal.html b/wcs/templates/wcs/backoffice/journal.html new file mode 100644 index 000000000..a62804e35 --- /dev/null +++ b/wcs/templates/wcs/backoffice/journal.html @@ -0,0 +1,50 @@ +{% extends "wcs/backoffice.html" %} +{% load i18n %} + +{% block content %} + {% block appbar %} +
+

{% trans "Audit Journal" %}

+
+ {% endblock %} + + + + + + + + + + + {% for line in lines %} + + + + + + {% endfor %} + +
{% trans "Date" %}{% trans "User" %}{% trans "Action" %}
{{ line.timestamp|date:"DATETIME_FORMAT" }}{{ line.frozen.user_full_name|default:"-" }}{{ line.get_action_description }}
+ + + +{% endblock %} + +{% block sidebar-content %} +

{% trans "Search" %}

+ {{ html_form.render|safe }} +{% endblock %}