From ed81175e242cf20deb719859d5b442b487c11e8a Mon Sep 17 00:00:00 2001 From: Paul Marillonnet Date: Wed, 31 Oct 2018 16:04:36 +0100 Subject: [PATCH] WIP add journal application (#20695) --- MANIFEST.in | 2 + setup.py | 1 + src/authentic2/settings.py | 1 + src/authentic2_journal/__init__.py | 26 +++ src/authentic2_journal/admin.py | 0 src/authentic2_journal/app_settings.py | 38 ++++ src/authentic2_journal/apps.py | 44 +++++ src/authentic2_journal/event_logger.py | 59 +++++++ src/authentic2_journal/migrations/__init__.py | 0 src/authentic2_journal/models.py | 71 ++++++++ src/authentic2_journal/tables.py | 27 +++ .../authentic2_journal/user_events.html | 10 ++ src/authentic2_journal/urls.py | 26 +++ src/authentic2_journal/utils.py | 75 ++++++++ src/authentic2_journal/views.py | 45 +++++ tests/test_journal.py | 166 ++++++++++++++++++ 16 files changed, 591 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/event_logger.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/user_events.html create mode 100644 src/authentic2_journal/urls.py create mode 100644 src/authentic2_journal/utils.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 b0517243..79bcf8ed 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -22,6 +22,7 @@ recursive-include src/authentic2/manager/templates *.html recursive-include src/authentic2_auth_saml/templates/authentic2_auth_saml *.html recursive-include src/authentic2_auth_oidc/templates/authentic2_auth_oidc *.html recursive-include src/authentic2_idp_oidc/templates/authentic2_idp_oidc *.html +recursive-include src/authentic2_journal/templates/authentic2_journal *.html recursive-include src/authentic2/vendor/totp_js/js *.js recursive-include src/authentic2/saml/fixtures *.json @@ -41,6 +42,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 27bb3c64..a1027688 100755 --- a/setup.py +++ b/setup.py @@ -165,5 +165,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/settings.py b/src/authentic2/settings.py index 8c9289a6..0ee122a2 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -126,6 +126,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..4e47d69a --- /dev/null +++ b/src/authentic2_journal/__init__.py @@ -0,0 +1,26 @@ +# 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 . + +class Plugin(object): + def get_after_urls(self): + from django.conf.urls import include, url + from . import urls + return [url(r'^manage/', include(urls))] + + 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 . + +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..3f8c2b89 --- /dev/null +++ b/src/authentic2_journal/apps.py @@ -0,0 +1,44 @@ +# 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 . + +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' + + def ready(self): + from django.db.models.signals import post_migrate + + post_migrate.connect( + self.create_event_kinds, + sender=self) + + def create_event_kinds(self, app_config, verbosity=2, interactive=True, + using=DEFAULT_DB_ALIAS, **kwargs): + from authentic2_journal.models import EventKind, KINDS + + if not router.allow_migrate(using, EventKind): + return + if EventKind.objects.filter(name__in=KINDS).count() == 9: + return + + for kind in KINDS: + EventKind.objects.get_or_create(name=kind) diff --git a/src/authentic2_journal/event_logger.py b/src/authentic2_journal/event_logger.py new file mode 100644 index 00000000..0641424b --- /dev/null +++ b/src/authentic2_journal/event_logger.py @@ -0,0 +1,59 @@ +# 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 . + +from authentic2_journal.models import Line +from authentic2_journal.utils import new_line, new_reference, log_event +from django.utils.translation import ugettext_lazy as _ + + +def sso(user, service): + representation = _('SSO: user %s on service %s') % (user.email, service) + log_event(user, service, 'action_sso', representation) + + +def login(user, provider): + representation = _('LOGIN: user %s on provider %s') % (user.email, provider) + log_event(user, provider, 'action_login', representation) + + +def logout(user, provider): + representation = _('LOGOUT: user %s on provider %s') % (user.email, provider) + log_event(user, provider, 'action_logout', representation) + + +def register(user, ou): + representation = _('REGISTER: user %s in OU %s') % (user.email, ou) + log_event(user, ou, 'action_register', representation) + + +def create(user, obj): + representation = _('CREATE: user %s on %s') % (user.email, obj) + log_event(user, obj, 'modification_create', representation) + + +def update(user, obj): + representation = _('UPDATE: user %s on %s') % (user.email, obj) + log_event(user, obj, 'modification_update', representation) + + +def modify_members(user, role): + representation = _('MODIFY MEMBERS: user %s on role %s') % (user.email, role.slug) + log_event(user, role, 'modification_modify_members', representation) + + +def delete(user, obj): + representation = _('DELETE: user %s on %s') % (user.email, obj) + log_event(user, obj, 'modification_delete', representation) 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..989d44da --- /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 . + +from __future__ import unicode_literals +from django.db import models +try: + from django.contrib.contenttypes.fields import GenericForeignKey +except ImportError: + from django.contrib.contenttypes.generic import GenericForeignKey +try: + from django.contrib.postgres.fields import JSONField # dj1.11 native JSON +except: + from jsonfield import JSONField # django-jsonfield outer module +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext_lazy as _ + + +KINDS = ( + 'action_register', + 'action_login', + 'action_sso', + 'action_logout', + 'modification_create', + 'modification_update', + 'modification_modify_members', + 'modification_delete', + 'generic_event' +) + + +def get_choices(): + return tuple((kind, kind) for kind in KINDS) + + +class EventKind(models.Model): + name = models.CharField( + verbose_name=_('name'), choices=get_choices(), max_length=256) + + +class Line(models.Model): + timestamp = models.DateTimeField(auto_now=True, verbose_name=_('timestamp')) + kind = models.ForeignKey(EventKind) + extra = JSONField(blank=True,null=True, verbose_name=_('content')) + + +class Reference(models.Model): + timestamp = models.DateTimeField(verbose_name=_('timestamp')) + line = models.ForeignKey(Line) + representation = models.TextField(max_length=256) + 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}) + models.Model.__init__(self, *args, **kwargs) diff --git a/src/authentic2_journal/tables.py b/src/authentic2_journal/tables.py new file mode 100644 index 00000000..10c47d1f --- /dev/null +++ b/src/authentic2_journal/tables.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 . + +import django_tables2 as tables +from authentic2_journal.models import Reference +from django.utils.translation import ugettext_lazy as _ + + +class UserEventsTable(tables.Table): + class Meta: + attrs = {'class': 'main'} + model = Reference + exclude = ['line', 'target_id', 'target_ct'] + empty_text = _('None') diff --git a/src/authentic2_journal/templates/authentic2_journal/user_events.html b/src/authentic2_journal/templates/authentic2_journal/user_events.html new file mode 100644 index 00000000..4af22975 --- /dev/null +++ b/src/authentic2_journal/templates/authentic2_journal/user_events.html @@ -0,0 +1,10 @@ +{% extends "authentic2/manager/base.html" %} +{% load i18n staticfiles django_tables2 %} + +{% block page_title %} +{% trans "User events journal" %} +{% endblock %} + +{% block content %} + {% render_table table "authentic2/manager/table.html" %} +{% endblock %} diff --git a/src/authentic2_journal/urls.py b/src/authentic2_journal/urls.py new file mode 100644 index 00000000..45570e50 --- /dev/null +++ b/src/authentic2_journal/urls.py @@ -0,0 +1,26 @@ +# 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 . + +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url('^users/(?P\d+)/journal/$', + views.user_journal, + name='a2-journal-user'), +] diff --git a/src/authentic2_journal/utils.py b/src/authentic2_journal/utils.py new file mode 100644 index 00000000..3c833034 --- /dev/null +++ b/src/authentic2_journal/utils.py @@ -0,0 +1,75 @@ +# 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 . + +import logging + +from authentic2_journal.models import EventKind, Line, Reference +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist + + +def new_line(user, obj, kind='generic_event', extra={}): + logger = logging.getLogger(__name__) + try: + kind = EventKind.objects.get(name=kind) + except: + logger.error('EventKind retrieval from model failed for event name %s' % + kind) + else: + try: + line = Line.objects.create( + kind=kind, + extra=extra + ) + except: + logger.error("Couldn't create event journal entry for user %s on " + "object %s" % (user, obj)) + else: + return line + + +def new_reference(line, obj, representation): + logger = logging.getLogger(__name__) + try: + ct = ContentType.objects.get_for_model(obj) + except ObjectDoesNotExist: + logger.error('ContentType retrieval from model failed for object %s' % + obj) + logger.warning('No reference will be created for line %s' % line) + else: + try: + reference = Reference.objects.create( + line=line, + representation=representation, + target_ct=ct, + target_id=obj.pk) + except: + logger.error("Couldn't create event reference for line %s and " + "object %s" % (line, obj)) + else: + return reference + + +def log_event(user, obj, kind, representation): + extra = { + 'user_email': user.email, + 'user_pk': user.pk, + 'obj_representation': '%s' % obj, + 'kind': kind + } + line = new_line(user, obj, kind, extra) + new_reference(line, user, representation) + new_reference(line, obj, representation) diff --git a/src/authentic2_journal/views.py b/src/authentic2_journal/views.py new file mode 100644 index 00000000..528b98b1 --- /dev/null +++ b/src/authentic2_journal/views.py @@ -0,0 +1,45 @@ +# 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 . + +from authentic2.manager.views import SimpleSubTableView +from authentic2_journal.tables import UserEventsTable +from authentic2_journal.models import Reference +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class BaseJournal(SimpleSubTableView): + model = models.Model + 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 + + def get_table_queryset(self): + return Reference.objects.filter( + target_id=self.object.pk, + target_ct=ContentType.objects.get_for_model(self.object)) + + +class UserJournal(BaseJournal): + model = get_user_model() + table_class = UserEventsTable + template_name = 'authentic2_journal/user_events.html' + permissions = ['custom_user.view_user'] + filter_table_by_perm = False + +user_journal = UserJournal.as_view() diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 00000000..b80d11c5 --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,166 @@ +# -*- 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.event_logger import (sso, login, logout, register, + create, update, modify_members, delete) +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 + + +def test_action_events(db, 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()) + + register(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 + + 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 + + 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 + + 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, 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()) + + create(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 + + 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 + + delete(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 + + modify_members(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, admin): + r1 = Role.objects.create(name='r1', slug='r1') + modify_members(admin, r1) + line = Line.objects.first() + for ref in Reference.objects.all(): + assert ref.timestamp == line.timestamp + + +def test_line_retrieval_on_object_deletion(db): + email = 'toto@nowhere.null' + role_name = role_slug = 'r1' + + entries_role = [] + entries_user = [] + for line in Line.objects.all(): + if role_name in line.extra['obj_representation']: + entries_role.append(line) + if email == line.extra['user_email']: + entries_user.append(line) + assert not len(entries_role) + assert not len(entries_user) + + role = Role.objects.create(name=role_name, slug=role_slug) + user = get_user_model().objects.create(email=email) + modify_members(user, role) + user.delete() + role.delete() + + entries_role = [] + entries_user = [] + for line in Line.objects.all(): + if role_name in line.extra['obj_representation']: + entries_role.append(line) + if email == line.extra['user_email']: + entries_user.append(line) + assert len(entries_role) + assert len(entries_user) + + +def test_reference_retrieval_after_objects_deletion(db): + email = 'toto@nowhere.null' + role_name = role_slug = 'r1' + + entries_role = [] + entries_user = [] + for ref in Reference.objects.all(): + if role_name in ref.representation: + entries_role.append(ref) + if email in ref.representation: + entries_user.append(ref) + assert not len(entries_role) + assert not len(entries_user) + + role = Role.objects.create(name=role_name, slug=role_slug) + user = get_user_model().objects.create(email=email) + modify_members(user, role) + user.delete() + role.delete() + + entries_role = [] + entries_user = [] + for ref in Reference.objects.all(): + if role_name in ref.representation: + entries_role.append(ref) + if email in ref.representation: + entries_user.append(ref) + assert len(entries_role) + assert len(entries_user) -- 2.19.1