From 78b07aa49792763a5a2005643e6bafd330671231 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 19 May 2021 17:29:38 +0200 Subject: [PATCH] api: record actions in journal (#48010) --- src/authentic2/api_views.py | 39 +++++++++- .../apps/journal/migrations/0002_event_api.py | 18 +++++ src/authentic2/apps/journal/models.py | 9 ++- src/authentic2/apps/journal/search_engine.py | 9 +++ .../journal/templates/journal/event_list.html | 2 +- src/authentic2/apps/journal/utils.py | 3 + src/authentic2/manager/journal_event_types.py | 42 ++++++----- tests/test_api.py | 71 +++++++++++++++++-- tests/test_manager_journal.py | 11 ++- tests/utils.py | 14 ++-- 10 files changed, 186 insertions(+), 32 deletions(-) create mode 100644 src/authentic2/apps/journal/migrations/0002_event_api.py diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py index 3728bf12a..ed28fd9b1 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -343,6 +343,7 @@ class PasswordChange(BaseRpcView): def rpc(self, request, serializer): serializer.user.set_password(serializer.validated_data['new_password']) serializer.user.save() + request.journal.record('manager.user.password.change', form=serializer, api=True) return {'result': 1}, status.HTTP_200_OK @@ -778,8 +779,19 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin if not self.request.user.has_perm(perm): raise PermissionDenied(u'You do not have permission %s' % perm) + def perform_create(self, serializer): + super().perform_create(serializer) + self.request.journal.record('manager.user.creation', form=serializer, api=True) + + def perform_update(self, serializer): + super().perform_update(serializer) + attributes = serializer.validated_data.pop('attributes', {}) + serializer.validated_data.update(attributes) + self.request.journal.record('manager.user.profile.edit', form=serializer, api=True) + def perform_destroy(self, instance): self.check_perm('custom_user.delete_user', instance.ou) + self.request.journal.record('manager.user.deletion', target_user=instance, api=True) super(UsersAPI, self).perform_destroy(instance) class SynchronizationSerializer(serializers.Serializer): @@ -820,6 +832,7 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin ) utils.send_password_reset_mail(user, request=request) + request.journal.record('manager.user.password.reset.request', target_user=user, api=True) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=['post'], permission_classes=(DjangoPermission('custom_user.change_user'),)) @@ -875,8 +888,17 @@ class RolesAPI(api_mixins.GetOrCreateMixinView, ExceptionHandlerMixin, ModelView def perform_destroy(self, instance): if not self.request.user.has_perm(perm='a2_rbac.delete_role', obj=instance): raise PermissionDenied(u'User %s can\'t create role %s' % (self.request.user, instance)) + self.request.journal.record('manager.role.deletion', role=instance, api=True) super(RolesAPI, self).perform_destroy(instance) + def perform_create(self, serializer): + super().perform_create(serializer) + self.request.journal.record('manager.role.creation', role=serializer.instance, api=True) + + def perform_update(self, serializer): + super().perform_update(serializer) + self.request.journal.record('manager.role.edit', role=serializer.instance, form=serializer, api=True) + class RolesMembersAPI(UsersAPI): def initial(self, request, *args, **kwargs): @@ -919,6 +941,7 @@ class RoleMembershipAPI(ExceptionHandlerMixin, APIView): if not request.user.has_perm('a2_rbac.manage_members_role', obj=self.role): raise PermissionDenied(u'User not allowed to manage role members') self.role.members.add(self.member) + request.journal.record('manager.role.membership.grant', role=self.role, member=self.member, api=True) return Response( {'result': 1, 'detail': _('User successfully added to role')}, status=status.HTTP_201_CREATED ) @@ -927,6 +950,9 @@ class RoleMembershipAPI(ExceptionHandlerMixin, APIView): if not request.user.has_perm('a2_rbac.manage_members_role', obj=self.role): raise PermissionDenied(u'User not allowed to manage role members') self.role.members.remove(self.member) + request.journal.record( + 'manager.role.membership.removal', role=self.role, member=self.member, api=True + ) return Response( {'result': 1, 'detail': _('User successfully removed from role')}, status=status.HTTP_200_OK ) @@ -944,7 +970,7 @@ class RoleMembershipsAPI(ExceptionHandlerMixin, APIView): Role = get_role_model() User = get_user_model() self.role = get_object_or_404(Role, uuid=kwargs['role_uuid']) - self.members = [] + self.members = set() perm = 'a2_rbac.manage_members_role' authorized = request.user.has_perm(perm, obj=self.role) @@ -967,7 +993,7 @@ class RoleMembershipsAPI(ExceptionHandlerMixin, APIView): _("Missing 'uuid' key for dict entry %s " "of the 'data' payload") % entry ) try: - self.members.append(User.objects.get(uuid=uuid)) + self.members.add(User.objects.get(uuid=uuid)) except User.DoesNotExist: raise ValidationError(_('No known user for UUID %s') % entry['uuid']) @@ -976,18 +1002,27 @@ class RoleMembershipsAPI(ExceptionHandlerMixin, APIView): def post(self, request, *args, **kwargs): self.role.members.add(*self.members) + for member in self.members: + request.journal.record('manager.role.membership.grant', role=self.role, member=member, api=True) return Response( {'result': 1, 'detail': _('Users successfully added to role')}, status=status.HTTP_201_CREATED ) def delete(self, request, *args, **kwargs): self.role.members.remove(*self.members) + for member in self.members: + request.journal.record('manager.role.membership.removal', role=self.role, member=member, api=True) return Response( {'result': 1, 'detail': _('Users successfully removed from role')}, status=status.HTTP_200_OK ) def patch(self, request, *args, **kwargs): + old_members = set(self.role.members.all()) self.role.members.set(self.members) + for member in self.members: + request.journal.record('manager.role.membership.grant', role=self.role, member=member, api=True) + for member in old_members.difference(self.members): + request.journal.record('manager.role.membership.removal', role=self.role, member=member, api=True) return Response( {'result': 1, 'detail': _('Users successfully assigned to role')}, status=status.HTTP_200_OK ) diff --git a/src/authentic2/apps/journal/migrations/0002_event_api.py b/src/authentic2/apps/journal/migrations/0002_event_api.py new file mode 100644 index 000000000..f4160956f --- /dev/null +++ b/src/authentic2/apps/journal/migrations/0002_event_api.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.19 on 2021-05-19 15:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('journal', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='api', + field=models.BooleanField(default=False, verbose_name='API'), + ), + ] diff --git a/src/authentic2/apps/journal/models.py b/src/authentic2/apps/journal/models.py index d5302f4fa..d7424227e 100644 --- a/src/authentic2/apps/journal/models.py +++ b/src/authentic2/apps/journal/models.py @@ -86,15 +86,20 @@ class EventTypeDefinition(metaclass=EventTypeDefinitionMeta): retention_days = None @classmethod - def record(cls, user=None, session=None, references=None, data=None): + def record(cls, user=None, session=None, references=None, data=None, api=False): event_type = EventType.objects.get_for_name(cls.name) + if user and not isinstance(user, User): + # API user from DRF or OIDC + user = None + Event.objects.create( type=event_type, user=user, session_id=session and session.session_key, references=references or None, # NULL values take less space data=data or None, # NULL values take less space + api=api, ) @classmethod @@ -321,6 +326,8 @@ class Event(models.Model): data = JSONField(verbose_name=_('data'), null=True) + api = models.BooleanField(verbose_name=_('API'), default=False) + objects = EventManager.from_queryset(EventQuerySet)() def __init__(self, *args, **kwargs): diff --git a/src/authentic2/apps/journal/search_engine.py b/src/authentic2/apps/journal/search_engine.py index e3d3669cd..830da6233 100644 --- a/src/authentic2/apps/journal/search_engine.py +++ b/src/authentic2/apps/journal/search_engine.py @@ -136,3 +136,12 @@ to users with this email address..''' You can use username:john to find all events related \ to users whose username is john.''' ) + + def search_by_api(self, lexem): + yield Q(api=bool(lexem == 'true')) + + search_by_api.documentation = _( + '''\ +You can use api:true to find all events related \ +to API calls.''' + ) diff --git a/src/authentic2/apps/journal/templates/journal/event_list.html b/src/authentic2/apps/journal/templates/journal/event_list.html index 049a8ff26..ce8eb200f 100644 --- a/src/authentic2/apps/journal/templates/journal/event_list.html +++ b/src/authentic2/apps/journal/templates/journal/event_list.html @@ -16,7 +16,7 @@ {% block event-timestamp %}{{ event.timestamp }}{% endblock %} {% block event-user %}{% firstof event.user.get_full_name event.user "-" %}{% endblock %} - {% block event-session %}{{ event.session_id_shortened|default:"-" }}{% endblock %} + {% block event-session %}{% if event.api %}API{% else %}{{ event.session_id_shortened|default:"-" }}{% endif %}{% endblock %} {% block event-message %}{{ event.message|default:"-" }}{% endblock %} {% if forloop.last %} diff --git a/src/authentic2/apps/journal/utils.py b/src/authentic2/apps/journal/utils.py index a5dbdee61..be9155019 100644 --- a/src/authentic2/apps/journal/utils.py +++ b/src/authentic2/apps/journal/utils.py @@ -26,6 +26,9 @@ def _json_value(value): def form_to_old_new(form): + if hasattr(form, 'validated_data'): + # form is a DRF serializer + return {'new': {k: _json_value(v) for k, v in form.validated_data.items()}} old = {} new = {} for key in form.changed_data: diff --git a/src/authentic2/manager/journal_event_types.py b/src/authentic2/manager/journal_event_types.py index 2445e7ef8..49a7cf3dc 100644 --- a/src/authentic2/manager/journal_event_types.py +++ b/src/authentic2/manager/journal_event_types.py @@ -42,8 +42,8 @@ class ManagerUserCreation(EventTypeDefinition): label = _('user creation') @classmethod - def record(cls, user, session, form): - super().record(user=user, session=session, references=[form.instance]) + def record(cls, user, session, form, api=False): + super().record(user=user, session=session, references=[form.instance], api=api) @classmethod def get_message(cls, event, context): @@ -62,8 +62,10 @@ class ManagerUserProfileEdit(EventTypeDefinition): label = _('user profile edit') @classmethod - def record(cls, user, session, form): - super().record(user=user, session=session, references=[form.instance], data=form_to_old_new(form)) + def record(cls, user, session, form, api=False): + super().record( + user=user, session=session, references=[form.instance], data=form_to_old_new(form), api=api + ) @classmethod def get_message(cls, event, context): @@ -107,12 +109,13 @@ class ManagerUserPasswordChange(EventTypeDefinition): label = _('user password change') @classmethod - def record(cls, user, session, form): + def record(cls, user, session, form, api=False): + cleaned_data = getattr(form, 'cleaned_data', {}) data = { - 'generate_password': form.cleaned_data['generate_password'], - 'send_mail': form.cleaned_data['send_mail'], + 'generate_password': cleaned_data.get('generate_password', False), + 'send_mail': cleaned_data.get('send_mail', False), } - super().record(user=user, session=session, references=[form.instance], data=data) + super().record(user=user, session=session, references=[form.instance], data=data, api=api) @classmethod def get_message(cls, event, context): @@ -137,9 +140,9 @@ class ManagerUserPasswordResetRequest(EventTypeDefinition): label = _('user password reset request') @classmethod - def record(cls, user, session, target_user): + def record(cls, user, session, target_user, api=False): super().record( - user=user, session=session, references=[target_user], data={'email': target_user.email} + user=user, session=session, references=[target_user], data={'email': target_user.email}, api=api ) @classmethod @@ -256,8 +259,8 @@ class ManagerUserDeletion(EventTypeDefinition): label = _('user deletion') @classmethod - def record(cls, user, session, target_user): - super().record(user=user, session=session, references=[target_user]) + def record(cls, user, session, target_user, api=False): + super().record(user=user, session=session, references=[target_user], api=api) @classmethod def get_message(cls, event, context): @@ -296,7 +299,7 @@ class ManagerUserSSOAuthorizationDeletion(EventTypeWithService): class RoleEventsMixin(EventTypeDefinition): @classmethod - def record(self, user, session, role, references=None, data=None): + def record(self, user, role, session=None, references=None, data=None, api=False): references = references or [] references = [role] + references data = data or {} @@ -306,6 +309,7 @@ class RoleEventsMixin(EventTypeDefinition): session=session, references=references, data=data, + api=api, ) @@ -328,8 +332,8 @@ class ManagerRoleEdit(RoleEventsMixin): label = _('role edit') @classmethod - def record(cls, user, session, role, form): - super().record(user=user, session=session, role=role, data=form_to_old_new(form)) + def record(cls, user, session, role, form, api=False): + super().record(user=user, session=session, role=role, data=form_to_old_new(form), api=api) @classmethod def get_message(cls, event, context): @@ -362,9 +366,9 @@ class ManagerRoleMembershipGrant(RoleEventsMixin): label = _('role membership grant') @classmethod - def record(cls, user, session, role, member): + def record(cls, user, session, role, member, api=False): data = {'member_name': member.get_full_name()} - super().record(user=user, session=session, role=role, references=[member], data=data) + super().record(user=user, session=session, role=role, references=[member], data=data, api=api) @classmethod def get_message(cls, event, context): @@ -384,9 +388,9 @@ class ManagerRoleMembershipRemoval(RoleEventsMixin): label = _('role membership removal') @classmethod - def record(cls, user, session, role, member): + def record(cls, user, session, role, member, api=False): data = {'member_name': member.get_full_name()} - super().record(user=user, session=session, role=role, references=[member], data=data) + super().record(user=user, session=session, role=role, references=[member], data=data, api=api) @classmethod def get_message(cls, event, context): diff --git a/tests/test_api.py b/tests/test_api.py index e91f1108f..d6939eb9f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -42,7 +42,7 @@ from authentic2.utils import good_next_url from django_rbac.models import SEARCH_OP from django_rbac.utils import get_ou_model, get_role_model -from .utils import basic_authorization_header, get_link_from_mail, login +from .utils import assert_event, basic_authorization_header, get_link_from_mail, login pytestmark = pytest.mark.django_db @@ -243,6 +243,7 @@ def test_api_users_update_with_email_verified(settings, app, admin, simple_user) user = User.objects.get(id=simple_user.id) assert user.email_verified assert resp.json['email_verified'] + assert_event('manager.user.profile.edit', user=admin, api=True) user.email_verified = True user.email = 'johnny.doeny@foo.bar' @@ -329,6 +330,7 @@ def test_api_users_create_with_email_verified(settings, app, admin): assert resp.json['email_verified'] user = User.objects.get(uuid=resp.json['uuid']) assert user.email_verified + assert_event('manager.user.creation', user=admin, api=True) def test_api_users_create_without_email_verified(settings, app, admin): @@ -786,6 +788,13 @@ def test_api_role_add_member(app, api_user, role, member): elif authorized: assert resp.json['result'] == 1 assert resp.json['detail'] == 'User successfully added to role' + assert_event( + 'manager.role.membership.grant', + user=api_user if isinstance(api_user, User) else None, + api=True, + role_name=role.name, + member_name=member.get_full_name(), + ) else: assert resp.json['result'] == 0 assert resp.json['errors'] == 'User not allowed to manage role members' @@ -812,6 +821,13 @@ def test_api_role_remove_member(app, api_user, role, member): assert resp.json['detail'] == 'User successfully removed from role' resp = app.get('/api/roles/{0}/members/{1}/'.format(role.uuid, member.uuid), status=404) assert resp.json == {'result': 0, 'errors': {'detail': 'Not found.'}} + assert_event( + 'manager.role.membership.removal', + user=api_user if isinstance(api_user, User) else None, + api=True, + role_name=role.name, + member_name=member.get_full_name(), + ) else: assert resp.json['result'] == 0 assert resp.json['errors'] == 'User not allowed to manage role members' @@ -847,6 +863,13 @@ def test_api_role_add_members(app, api_user, role, member, member_rando2): assert resp.json['detail'] == 'Users successfully added to role' for m in [member, member_rando2]: assert m in role.members.all() + assert_event( + 'manager.role.membership.grant', + user=api_user if isinstance(api_user, User) else None, + api=True, + role_name=role.name, + member_name=m.get_full_name(), + ) else: assert resp.json['result'] == 0 assert resp.json['errors'] == 'User not allowed to manage role members' @@ -882,12 +905,22 @@ def test_api_role_remove_members(app, api_user, role, member, member_rando2): assert resp.json['detail'] == 'Users successfully removed from role' for m in [member, member_rando2]: assert m not in role.members.all() + assert_event( + 'manager.role.membership.removal', + user=api_user if isinstance(api_user, User) else None, + api=True, + role_name=role.name, + member_name=m.get_full_name(), + ) else: assert resp.json['result'] == 0 assert resp.json['errors'] == 'User not allowed to manage role members' -def test_api_role_set_members(app, api_user, role, member, member_rando2): +def test_api_role_set_members(app, api_user, role, member, member_rando2, ou_rando): + user = User.objects.create( + username='test3', first_name='test3', last_name='test3', email='test3@test.org', ou=ou_rando + ) app.authorization = ('Basic', (api_user.username, api_user.username)) authorized = api_user.has_perm('a2_rbac.manage_members_role', role) @@ -903,6 +936,7 @@ def test_api_role_set_members(app, api_user, role, member, member_rando2): payload = {"data": []} + role.members.add(user) for m in [member, member_rando2, member_rando2]: # test no duplicate payload['data'].append({"uuid": m.uuid}) @@ -918,6 +952,20 @@ def test_api_role_set_members(app, api_user, role, member, member_rando2): assert len(role.members.all()) == 2 for m in [member, member_rando2]: assert m in role.members.all() + assert_event( + 'manager.role.membership.grant', + user=api_user if isinstance(api_user, User) else None, + api=True, + role_name=role.name, + member_name=m.get_full_name(), + ) + assert_event( + 'manager.role.membership.removal', + user=api_user if isinstance(api_user, User) else None, + api=True, + role_name=role.name, + member_name=user.get_full_name(), + ) else: assert resp.json['result'] == 0 assert resp.json['errors'] == 'User not allowed to manage role members' @@ -1226,6 +1274,7 @@ def test_password_change(app, ou1, admin): response = app.post_json(url, params=payload) assert response.json['result'] == 1 assert User.objects.get(username='john.doe').check_password('password2') + assert_event('manager.user.password.change', user=admin, api=True) def test_password_reset(app, ou1, admin, user_ou1, mailoutbox): @@ -1247,6 +1296,7 @@ def test_password_reset(app, ou1, admin, user_ou1, mailoutbox): mail = mailoutbox[0] assert mail.to[0] == email assert 'http://testserver/accounts/password/reset/confirm/' in mail.body + assert_event('manager.user.password.reset.request', user=admin, api=True) def test_users_email(app, ou1, admin, user_ou1, mailoutbox): @@ -1274,6 +1324,7 @@ def test_api_delete_role(app, admin_ou1, role_ou1): app.authorization = ('Basic', (admin_ou1.username, admin_ou1.username)) app.delete('/api/roles/{}/'.format(role_ou1.uuid)) assert not len(Role.objects.filter(slug='role_ou1')) + assert_event('manager.role.deletion', user=admin_ou1, api=True, role_name=role_ou1.name) def test_api_delete_role_unauthorized(app, simple_user, role_ou1): @@ -1289,6 +1340,7 @@ def test_api_patch_role(app, admin_ou1, role_ou1): 'slug': 'updated-role', } app.patch_json('/api/roles/{}/'.format(role_ou1.uuid), params=role_data) + assert_event('manager.role.edit', user=admin_ou1, api=True, role_name=role_ou1.name) # The role API won't change the organizational unit attribute: role_ou1.refresh_from_db() @@ -1317,6 +1369,7 @@ def test_api_put_role(app, admin_ou1, role_ou1, ou1): assert role_ou1.name == 'updated-role' assert role_ou1.slug == 'updated-role' assert role_ou1.ou.slug == 'ou1' + assert_event('manager.role.edit', user=admin_ou1, api=True, role_name=role_ou1.name) def test_api_put_role_unauthorized(app, simple_user, role_ou1, ou1): @@ -1334,6 +1387,7 @@ def test_api_post_role(app, admin_ou1, ou1): role_data = {'slug': 'coffee-manager', 'name': 'Coffee Manager', 'ou': 'ou1'} resp = app.post_json('/api/roles/', params=role_data) + assert_event('manager.role.creation', user=admin_ou1, api=True, role_name='Coffee Manager') assert isinstance(resp.json, dict) uuid = resp.json['uuid'] @@ -1870,12 +1924,14 @@ def test_api_users_required_date_attributes(settings, app, admin, simple_user): ] # update with values pass + del payload['id'] payload['prefered_color'] = 'blue' - payload['date'] = '1515-1-15' - payload['birthdate'] = '1900-2-22' + payload['date'] = '1515-01-15' + payload['birthdate'] = '1900-02-22' resp = app.put_json( '/api/users/{}/'.format(simple_user.uuid), params=payload, headers=headers, status=200 ) + assert_event('manager.user.profile.edit', user=admin, api=True, new=payload) # value are properly returned on a get resp = app.get('/api/users/{}/'.format(simple_user.uuid), headers=headers, status=200) @@ -2247,6 +2303,13 @@ def test_api_password_change_user_delete(app, settings, admin, ou1): assert User.objects.get(username='john.doe').check_password('password2') +def test_api_users_delete(settings, app, admin, simple_user): + headers = basic_authorization_header(admin) + resp = app.delete_json('/api/users/{}/'.format(simple_user.uuid), headers=headers) + assert not User.objects.filter(pk=simple_user.pk).exists() + assert_event('manager.user.deletion', user=admin, api=True) + + @pytest.mark.skipif(drf_version.startswith('3.4'), reason='no support for old django rest framework') def test_api_statistics_list(app, admin): OU = get_ou_model() diff --git a/tests/test_manager_journal.py b/tests/test_manager_journal.py index d42261108..c7e8e51b5 100644 --- a/tests/test_manager_journal.py +++ b/tests/test_manager_journal.py @@ -79,7 +79,7 @@ def events(db, freezer): make("user.login.failure", username="agent") make("user.login", user=user, session=session1, how="password") make("user.password.change", user=user, session=session1) - edit_profile_form = mock.Mock() + edit_profile_form = mock.Mock(spec=["instance", "initial", "changed_data", "cleaned_data"]) edit_profile_form.initial = {'email': "user@example.com", 'first_name': "John"} edit_profile_form.changed_data = ["first_name"] edit_profile_form.cleaned_data = {'first_name': "Jane"} @@ -1030,6 +1030,15 @@ def test_search(app, superuser, events): for p in zip(pq('tbody td.journal-list--user-column'), pq('tbody td.journal-list--message-column')) ] == [['Johnny doe', 'login using password']] + Event.objects.filter(type__name='manager.user.creation').update(api=True) + response.form.set('search', 'api:true') + response = response.form.submit() + assert ( + text_content(response.pyquery('tbody tr td.journal-list--message-column')[0]).strip() + == 'creation of user "Johnny doe"' + ) + assert text_content(response.pyquery('tbody tr td.journal-list--session-column')[0]).strip() == 'API' + response.form.set('search', '') response.form['event_type'].select(text='Profile changes') response = response.form.submit() diff --git a/tests/utils.py b/tests/utils.py index 70224feda..53656464b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -264,8 +264,8 @@ def text_content(node): return ''.join(node.itertext()) if node is not None else '' -def assert_event(event_type_name, user=None, session=None, service=None, target_user=None, **data): - qs = Event.objects.filter(type__name=event_type_name) +def assert_event(event_type_name, user=None, session=None, service=None, target_user=None, api=False, **data): + qs = Event.objects.filter(type__name=event_type_name, api=api) if user: qs = qs.filter(user=user) else: @@ -281,9 +281,13 @@ def assert_event(event_type_name, user=None, session=None, service=None, target_ if target_user: qs = qs.which_references(target_user) - assert qs.count() == 1 + count = qs.count() + assert count > 0 - if data: + if not data: + assert count == 1 + + if data and count == 1: event = qs.get() assert event.data, 'no event.data, should be %s' % data for key, value in data.items(): @@ -293,6 +297,8 @@ def assert_event(event_type_name, user=None, session=None, service=None, target_ event.data.get(key), value, ) + elif data and count > 1: + assert qs.filter(**{'data__' + k: v for k, v in data.items()}).count() == 1 @httmock.HTTMock