From e910038f349812ae4103611551c932d2610ef8b1 Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Thu, 17 Jan 2019 12:22:48 +0100
Subject: [PATCH 1/5] add journal application (#20695)

---
 MANIFEST.in                                   |   1 +
 setup.py                                      |   1 +
 src/authentic2/app_settings.py                |   1 +
 src/authentic2/settings.py                    |   1 +
 src/authentic2_journal/__init__.py            |  21 +++
 src/authentic2_journal/admin.py               |   0
 src/authentic2_journal/app_settings.py        |  38 +++++
 src/authentic2_journal/apps.py                |  25 +++
 src/authentic2_journal/kinds.py               | 104 ++++++++++++
 src/authentic2_journal/logger.py              |  21 +++
 .../migrations/0001_initial.py                |  42 +++++
 src/authentic2_journal/migrations/__init__.py |   0
 src/authentic2_journal/models.py              |  71 ++++++++
 src/authentic2_journal/tables.py              |  35 ++++
 .../authentic2_journal/creation.html          |   2 +
 .../authentic2_journal/deletion.html          |   2 +
 .../templates/authentic2_journal/login.html   |   2 +
 .../templates/authentic2_journal/logout.html  |   2 +
 .../members_modification.html                 |   2 +
 .../authentic2_journal/registration.html      |   2 +
 .../templates/authentic2_journal/sso.html     |   2 +
 .../templates/authentic2_journal/update.html  |   2 +
 .../templatetags/__init__.py                  |   0
 .../templatetags/authentic2_journal.py        |  27 +++
 src/authentic2_journal/views.py               |  34 ++++
 tests/conftest.py                             |   6 +
 tests/test_journal.py                         | 158 ++++++++++++++++++
 27 files changed, 602 insertions(+)
 create mode 100644 src/authentic2_journal/__init__.py
 create mode 100644 src/authentic2_journal/admin.py
 create mode 100644 src/authentic2_journal/app_settings.py
 create mode 100644 src/authentic2_journal/apps.py
 create mode 100644 src/authentic2_journal/kinds.py
 create mode 100644 src/authentic2_journal/logger.py
 create mode 100644 src/authentic2_journal/migrations/0001_initial.py
 create mode 100644 src/authentic2_journal/migrations/__init__.py
 create mode 100644 src/authentic2_journal/models.py
 create mode 100644 src/authentic2_journal/tables.py
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/creation.html
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/deletion.html
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/login.html
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/logout.html
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/members_modification.html
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/registration.html
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/sso.html
 create mode 100644 src/authentic2_journal/templates/authentic2_journal/update.html
 create mode 100644 src/authentic2_journal/templatetags/__init__.py
 create mode 100644 src/authentic2_journal/templatetags/authentic2_journal.py
 create mode 100644 src/authentic2_journal/views.py
 create mode 100644 tests/test_journal.py

diff --git a/MANIFEST.in b/MANIFEST.in
index 046ab496..dd27a0a3 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -41,6 +41,7 @@ recursive-include src/django_rbac/locale *.po *.mo
 recursive-include src/authentic2_auth_saml/locale *.po *.mo
 recursive-include src/authentic2_auth_oidc/locale *.po *.mo
 recursive-include src/authentic2_idp_oidc/locale *.po *.mo
+recursive-include src/authentic2_journal/locale *.po *.mo
 
 recursive-include src/authentic2 README  xrds.xml *.txt yadis.xrdf
 recursive-include src/authentic2_provisionning_ldap/tests *.ldif
diff --git a/setup.py b/setup.py
index 85fee6ae..36105c55 100755
--- a/setup.py
+++ b/setup.py
@@ -166,5 +166,6 @@ setup(name="authentic2",
               'authentic2-idp-cas = authentic2_idp_cas:Plugin',
               'authentic2-idp-oidc = authentic2_idp_oidc:Plugin',
               'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin',
+              'authentic2-journal = authentic2_journal:Plugin',
           ],
       })
diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 67ecb553..79de2f35 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -207,6 +207,7 @@ default_settings = dict(
     A2_ACCOUNTS_URL=Setting(default=None, definition='IdP has no account page, redirect to this one.'),
     A2_CACHE_ENABLED=Setting(default=True, definition='Disable all cache decorators for testing purpose.'),
     A2_ACCEPT_EMAIL_AUTHENTICATION=Setting(default=True, definition='Enable authentication by email'),
+    A2_JOURNAL_EVENT_LOGGER=Setting(default=None, definition='Event logger class for the journal app.'),
 
 )
 
diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py
index 15a443e2..b602f502 100644
--- a/src/authentic2/settings.py
+++ b/src/authentic2/settings.py
@@ -124,6 +124,7 @@ INSTALLED_APPS = (
     'authentic2.disco_service',
     'authentic2.manager',
     'authentic2_provisionning_ldap',
+    'authentic2_journal',
     'authentic2',
     'django_rbac',
     'authentic2.a2_rbac',
diff --git a/src/authentic2_journal/__init__.py b/src/authentic2_journal/__init__.py
new file mode 100644
index 00000000..7d27c9b3
--- /dev/null
+++ b/src/authentic2_journal/__init__.py
@@ -0,0 +1,21 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+class Plugin(object):
+    def get_apps(self):
+        return [__name__]
+
+default_app_config = 'authentic2_journal.apps.Authentic2JournalConfig'
diff --git a/src/authentic2_journal/admin.py b/src/authentic2_journal/admin.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/authentic2_journal/app_settings.py b/src/authentic2_journal/app_settings.py
new file mode 100644
index 00000000..495e4e13
--- /dev/null
+++ b/src/authentic2_journal/app_settings.py
@@ -0,0 +1,38 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+class AppSettings(object):
+    __DEFAULTS = {
+            'ENABLE': False,
+    }
+
+    def __init__(self, prefix):
+        self.prefix = prefix
+
+    def _setting(self, name, dflt):
+        from django.conf import settings
+        return getattr(settings, self.prefix + name, dflt)
+
+    def __getattr__(self, name):
+        if name not in self.__DEFAULTS:
+            raise AttributeError(name)
+        return self._setting(name, self.__DEFAULTS[name])
+
+
+import sys
+app_settings = AppSettings('A2_JOURNAL_')
+app_settings.__name__ = __name__
+sys.modules[__name__] = app_settings
diff --git a/src/authentic2_journal/apps.py b/src/authentic2_journal/apps.py
new file mode 100644
index 00000000..ef8590ba
--- /dev/null
+++ b/src/authentic2_journal/apps.py
@@ -0,0 +1,25 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import unicode_literals
+
+from django.db import DEFAULT_DB_ALIAS, router
+from django.apps import AppConfig
+
+
+class Authentic2JournalConfig(AppConfig):
+    name = 'authentic2_journal'
+    verbose_name = 'Authentic 2 Journal Application'
diff --git a/src/authentic2_journal/kinds.py b/src/authentic2_journal/kinds.py
new file mode 100644
index 00000000..8e107cbe
--- /dev/null
+++ b/src/authentic2_journal/kinds.py
@@ -0,0 +1,104 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.contrib.contenttypes.models import ContentType
+from django.template.loader import render_to_string, select_template
+
+
+_kind_registry = {}
+
+
+class KindMetaclass(type):
+    def __init__(self, name, bases, dct):
+        super(KindMetaclass, self).__init__(name, bases, dct)
+        if name != "BaseKind":
+            assert set(dct.keys()) >= set(['name', 'template_names'])
+            assert dct['name'] not in _kind_registry
+            _kind_registry[dct['name']] = self
+
+
+class BaseKind(object):
+    __metaclass__ = KindMetaclass
+    long_template_names = []
+    template_names = []
+
+    def format(self, line):
+        return select_template(self.template_names).render(context=line.extra_json)
+
+    def long_format(self, line):
+        if not self.long_template_names:
+            return ''
+        return select_template(self.long_template_names).render(context=line.extra_json)
+
+    def log_real(self, **kwargs):
+        from authentic2_journal.models import Line, get_kind
+        kind = get_kind(self.name)
+        if isinstance(kind, tuple):
+            kind = kind[0]
+        return Line.objects.create(kind=kind, extra_json=kwargs)
+
+    def create_reference(self, line, obj, **kwargs):
+        from authentic2_journal.models import Reference
+        reference = Reference.objects.create(
+                line=line,
+                target_ct=ContentType.objects.get_for_model(obj),
+                target_id=obj.pk)
+
+    def log(self, user, obj):
+        line = self.log_real(user_pk=user.pk, obj_pk=obj.pk, user='%s' % user, obj='%s' % obj)
+        if line:
+            self.create_reference(line, user)
+            self.create_reference(line, obj)
+
+
+class SSOKind(BaseKind):
+    name = 'record_sso'
+    template_names = ['authentic2_journal/sso.html']
+
+
+class LoginKind(BaseKind):
+    name = 'record_login'
+    template_names = ['authentic2_journal/login.html']
+
+
+class LogoutKind(BaseKind):
+    name = 'record_logout'
+    template_names = ['authentic2_journal/logout.html']
+
+
+class RegisterKind(BaseKind):
+    name = 'record_registration'
+    template_names = ['authentic2_journal/registration.html']
+
+
+class CreateKind(BaseKind):
+    name = 'record_creation'
+    template_names = ['authentic2_journal/creation.html']
+
+
+class UpdateKind(BaseKind):
+    name = 'record_update'
+    template_names = ['authentic2_journal/update.html']
+
+
+class ModifyMembersKind(BaseKind):
+    name = 'record_members_modification'
+    template_names = ['authentic2_journal/members_modification.html']
+
+
+class DeleteKind(BaseKind):
+    name = 'record_deletion'
+    template_names = ['authentic2_journal/deletion.html']
diff --git a/src/authentic2_journal/logger.py b/src/authentic2_journal/logger.py
new file mode 100644
index 00000000..6f585c32
--- /dev/null
+++ b/src/authentic2_journal/logger.py
@@ -0,0 +1,21 @@
+from authentic2_journal import kinds, models
+
+
+class Logger(object):
+    def __init__(self, kind):
+        self.kind = kinds._kind_registry[kind]
+
+    def __call__(self, *args, **kwargs):
+        kind_model = models.get_kind(self.kind.name)
+        if isinstance(kind_model, tuple):
+            kind_model = kind_model[0]
+        kind = kind_model.concrete()
+        return self.kind.log(kind, *args, **kwargs)
+
+
+class LoggerHub(object):
+    def __getattr__(self, kind_name):
+        return Logger(kind_name)
+
+
+logger = LoggerHub()
diff --git a/src/authentic2_journal/migrations/0001_initial.py b/src/authentic2_journal/migrations/0001_initial.py
new file mode 100644
index 00000000..cab477f6
--- /dev/null
+++ b/src/authentic2_journal/migrations/0001_initial.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import authentic2.compat
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Kind',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('name', models.CharField(max_length=128, null=True, verbose_name='name')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Line',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('timestamp', models.DateTimeField(auto_now=True, verbose_name='timestamp')),
+                (b'extra_json', authentic2.compat.JSONField(null=True, verbose_name='extra JSON', blank=True)),
+                ('kind', models.ForeignKey(to='authentic2_journal.Kind')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Reference',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('timestamp', models.DateTimeField(verbose_name='timestamp')),
+                ('target_id', models.IntegerField(default=0)),
+                (b'extra_json', authentic2.compat.JSONField(null=False, verbose_name='additional description', blank=False)),
+                ('line', models.ForeignKey(to='authentic2_journal.Line')),
+                ('target_ct', models.ForeignKey(to='contenttypes.ContentType')),
+            ],
+        ),
+    ]
diff --git a/src/authentic2_journal/migrations/__init__.py b/src/authentic2_journal/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/authentic2_journal/models.py b/src/authentic2_journal/models.py
new file mode 100644
index 00000000..87b3155c
--- /dev/null
+++ b/src/authentic2_journal/models.py
@@ -0,0 +1,71 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import unicode_literals
+from authentic2.compat import JSONField
+from authentic2.decorators import GlobalCache
+from authentic2_journal import kinds
+from django.db import models
+try:
+    from django.contrib.contenttypes.fields import GenericForeignKey
+except ImportError:
+    from django.contrib.contenttypes.generic import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import ugettext_lazy as _
+
+
+class Kind(models.Model):
+    name = models.CharField(max_length=128, null=True, verbose_name='name')
+
+    @property
+    def concrete(self):
+        return kinds._kind_registry[self.name]
+
+
+# get_kind() should be cached, like ContentType are, because they never vary
+@GlobalCache
+def get_kind(name):
+    return Kind.objects.get_or_create(name=name)
+
+
+class Line(models.Model):
+    timestamp = models.DateTimeField(auto_now=True, verbose_name=_('timestamp'))
+    kind = models.ForeignKey(Kind)
+    extra_json = JSONField(blank=True, null=True, verbose_name=_('extra JSON'))
+
+    def format(self):
+        return self.kind.concrete().format(self)
+
+    def long_format(self):
+        return self.kind.concrete().long_format(self)
+
+
+class Reference(models.Model):
+    timestamp = models.DateTimeField(verbose_name=_('timestamp'))
+    line = models.ForeignKey(Line)
+    extra_json = JSONField(blank=False, null=False, verbose_name=_('additional description'))
+    target_ct = models.ForeignKey(ContentType)
+    target_id = models.IntegerField(default=0)
+    target = GenericForeignKey('target_ct', 'target_id')
+
+    def __init__(self, *args, **kwargs):
+        line = kwargs.get('line')
+        if line:
+            kwargs.update({
+                'timestamp': line.timestamp,
+                'extra_json': line.extra_json
+            })
+        return super(Reference, self).__init__(*args, **kwargs)
diff --git a/src/authentic2_journal/tables.py b/src/authentic2_journal/tables.py
new file mode 100644
index 00000000..a81baac6
--- /dev/null
+++ b/src/authentic2_journal/tables.py
@@ -0,0 +1,35 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import django_tables2 as tables
+
+from authentic2_journal.models import Line
+from django.utils.translation import ugettext_lazy as _
+
+
+class UserEventsTable(tables.Table):
+    def render_kind(self, value):
+        return value.name
+
+    def render_extra_json(self, value, record):
+        return record.format() or value
+
+    class Meta:
+        attrs = {'class': 'main'}
+        model = Line
+        order_by = ('-timestamp', 'id')
+        per_page = 10
+        empty_text = _('No journal entry yet.')
diff --git a/src/authentic2_journal/templates/authentic2_journal/creation.html b/src/authentic2_journal/templates/authentic2_journal/creation.html
new file mode 100644
index 00000000..ffbf405e
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/creation.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} performed create on {{obj}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templates/authentic2_journal/deletion.html b/src/authentic2_journal/templates/authentic2_journal/deletion.html
new file mode 100644
index 00000000..003f7e2d
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/deletion.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} performed delete on {{obj}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templates/authentic2_journal/login.html b/src/authentic2_journal/templates/authentic2_journal/login.html
new file mode 100644
index 00000000..0e4321df
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/login.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} performed login on {{provider}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templates/authentic2_journal/logout.html b/src/authentic2_journal/templates/authentic2_journal/logout.html
new file mode 100644
index 00000000..8b4343ed
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/logout.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} performed logout on {{provider}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templates/authentic2_journal/members_modification.html b/src/authentic2_journal/templates/authentic2_journal/members_modification.html
new file mode 100644
index 00000000..c3490ea9
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/members_modification.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} performed modify members on {{role}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templates/authentic2_journal/registration.html b/src/authentic2_journal/templates/authentic2_journal/registration.html
new file mode 100644
index 00000000..77125b52
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/registration.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} registered on {{ou}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templates/authentic2_journal/sso.html b/src/authentic2_journal/templates/authentic2_journal/sso.html
new file mode 100644
index 00000000..2754bab0
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/sso.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} performed sso on {{service}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templates/authentic2_journal/update.html b/src/authentic2_journal/templates/authentic2_journal/update.html
new file mode 100644
index 00000000..5e785b74
--- /dev/null
+++ b/src/authentic2_journal/templates/authentic2_journal/update.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}{{user}} performed update on {{obj}}{% endblocktrans %}
diff --git a/src/authentic2_journal/templatetags/__init__.py b/src/authentic2_journal/templatetags/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/authentic2_journal/templatetags/authentic2_journal.py b/src/authentic2_journal/templatetags/authentic2_journal.py
new file mode 100644
index 00000000..11e5bb82
--- /dev/null
+++ b/src/authentic2_journal/templatetags/authentic2_journal.py
@@ -0,0 +1,27 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django import template
+
+register = template.Library()
+
+@register.filter
+def to_representation(line):
+    return line.format()
+
+@register.filter
+def to_long_representation(line):
+    return line.long_format()
diff --git a/src/authentic2_journal/views.py b/src/authentic2_journal/views.py
new file mode 100644
index 00000000..6b824bd7
--- /dev/null
+++ b/src/authentic2_journal/views.py
@@ -0,0 +1,34 @@
+# authentic - Versatile identity management server
+# Copyright (C) 2018 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from authentic2.manager.views import SimpleSubTableView
+from authentic2_journal.models import Reference
+from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
+
+
+class ManagerBaseJournal(SimpleSubTableView):
+    model = None
+    table_class = None # TODO generic table class?
+    template_name = '' # TODO generic template name?
+    permissions = [] # TODO generic custom_user.view_object permission?
+    filter_table_by_perm = False
+    table_pagination = True
+
+    def get_table_queryset(self):
+        return [ref.line for ref in Reference.objects.filter(
+                target_id=self.object.pk,
+                target_ct=ContentType.objects.get_for_model(self.object))]
diff --git a/tests/conftest.py b/tests/conftest.py
index b5b5e78c..11477b98 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -344,3 +344,9 @@ def french_translation():
 @pytest.fixture
 def media(settings, tmpdir):
     settings.MEDIA_ROOT = str(tmpdir.mkdir('media'))
+
+
+@pytest.fixture
+def event_logger(db):
+    from authentic2_journal.logger import LoggerHub
+    return LoggerHub()
diff --git a/tests/test_journal.py b/tests/test_journal.py
new file mode 100644
index 00000000..92d79e1b
--- /dev/null
+++ b/tests/test_journal.py
@@ -0,0 +1,158 @@
+# -*- coding: utf-8 -*-
+
+from authentic2.a2_rbac.models import Role
+from authentic2.a2_rbac.utils import get_default_ou
+from authentic2.models import Service
+from authentic2.saml.models import (LibertyServiceProvider as SP,
+    LibertyProvider as IdP)
+from authentic2_journal.models import Line, Reference
+from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
+from django_rbac.utils import get_ou_model, get_role_model
+from utils import login
+
+
+def test_action_events(db, event_logger, app, admin, simple_user, ou1):
+    s1 = Service.objects.create(name='s1', slug='s1')
+    idp1 = IdP.objects.create(
+            name='idp',
+            slug='idp',
+            protocol_conformance=3)
+    sp1 = SP.objects.create(liberty_provider=idp1, enabled=True)
+    user_ct = ContentType.objects.get_for_model(get_user_model())
+    ou_ct = ContentType.objects.get_for_model(get_ou_model())
+    idp_ct = ContentType.objects.get_for_model(IdP)
+    sp_ct = ContentType.objects.get_for_model(SP)
+
+    assert not len(Line.objects.all())
+    assert not len(Reference.objects.all())
+
+    event_logger.record_registration(admin, ou1)
+    assert len(Line.objects.all()) == 1
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 1
+    assert len(Reference.objects.filter(
+            target_id=ou1.pk, target_ct=ou_ct)) == 1
+
+    event_logger.record_sso(admin, sp1)
+    assert len(Line.objects.all()) == 2
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 2
+    assert len(Reference.objects.filter(
+            target_id=sp1.pk, target_ct=sp_ct)) == 1
+
+    event_logger.record_login(admin, idp1)
+    assert len(Line.objects.all()) == 3
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 3
+    assert len(Reference.objects.filter(
+            target_id=idp1.pk, target_ct=idp_ct)) == 1
+
+    event_logger.record_logout(admin, idp1)
+    assert len(Line.objects.all()) == 4
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 4
+    assert len(Reference.objects.filter(
+            target_id=idp1.pk, target_ct=idp_ct)) == 2
+
+
+def test_modification_events(db, event_logger, admin, simple_user):
+    r1 = Role.objects.create(name='r1', slug='r1')
+    user_ct = ContentType.objects.get_for_model(get_user_model())
+    ou_ct = ContentType.objects.get_for_model(get_ou_model())
+    role_ct = ContentType.objects.get_for_model(get_role_model())
+
+    assert not len(Line.objects.all())
+    assert not len(Reference.objects.all())
+
+    event_logger.record_creation(admin, simple_user)
+    assert len(Line.objects.all()) == 1
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 1
+    assert len(Reference.objects.filter(
+            target_id=simple_user.pk, target_ct=user_ct)) == 1
+
+    event_logger.record_update(admin, simple_user)
+    assert len(Line.objects.all()) == 2
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 2
+    assert len(Reference.objects.filter(
+            target_id=simple_user.pk, target_ct=user_ct)) == 2
+
+    event_logger.record_deletion(admin, simple_user)
+    assert len(Line.objects.all()) == 3
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 3
+    assert len(Reference.objects.filter(
+            target_id=simple_user.pk, target_ct=user_ct)) == 3
+
+    event_logger.record_members_modification(admin, r1)
+    assert len(Line.objects.all()) == 4
+    assert len(Reference.objects.filter(
+            target_id=admin.pk, target_ct=user_ct)) == 4
+    assert len(Reference.objects.filter(
+            target_id=r1.pk, target_ct=role_ct)) == 1
+
+
+def test_entries_timestamp_consistency(db, event_logger, admin):
+    r1 = Role.objects.create(name='r1', slug='r1')
+    event_logger.record_members_modification(admin, r1)
+    line = Line.objects.first()
+    for ref in Reference.objects.all():
+        assert ref.timestamp == line.timestamp
+
+
+def test_line_retrieval_after_object_deletion(db, event_logger):
+    email = u'toto@nowhere.null'
+    role_name = role_slug = u'r1'
+
+    assert not len(Line.objects.all())
+
+    role = Role.objects.create(name=role_name, slug=role_slug)
+    user = get_user_model().objects.create(email=email)
+    event_logger.record_members_modification(user, role)
+    user.delete()
+    role.delete()
+
+    entries_role = []
+    entries_user = []
+    for line in Line.objects.all():
+        if role_name in line.extra_json['obj']:
+            entries_role.append(line)
+        if line.extra_json['user'].startswith(email):
+            entries_user.append(line)
+    assert len(entries_role)
+    assert len(entries_user)
+
+
+def test_reference_retrieval_after_objects_deletion(db, event_logger):
+    email = 'toto@nowhere.null'
+    role_name = role_slug = 'r1'
+
+    assert not(Reference.objects.all())
+
+    role = Role.objects.create(name=role_name, slug=role_slug)
+    user = get_user_model().objects.create(email=email)
+    event_logger.record_members_modification(user, role)
+    user.delete()
+    role.delete()
+
+    entries_role = []
+    entries_user = []
+    for ref in Reference.objects.all():
+        if role_name in ref.extra_json['obj']:
+            entries_role.append(ref)
+        if ref.extra_json.get('user', '').startswith(email):
+            entries_user.append(ref)
+    assert len(entries_role)
+    assert len(entries_user)
+
+
+def test_log_event_escalate_error(db, event_logger, app, admin, simple_user, ou1):
+    try:
+        event_logger.spoke_to(admin, simple_user)
+    except:
+        pass
+    else:
+        assert 0
-- 
2.20.1

