diff --git a/src/authentic2/custom_user/managers.py b/src/authentic2/custom_user/managers.py index 46aab0b4b..066846eae 100644 --- a/src/authentic2/custom_user/managers.py +++ b/src/authentic2/custom_user/managers.py @@ -78,13 +78,17 @@ class UserQuerySet(models.QuerySet): self = self.distinct() return self - def find_duplicates(self, first_name, last_name, birthdate=None): + def find_duplicates(self, first_name=None, last_name=None, fullname=None, birthdate=None): with connection.cursor() as cursor: cursor.execute( "SET pg_trgm.similarity_threshold = %f" % app_settings.A2_DUPLICATES_THRESHOLD ) - name = '%s %s' % (first_name, last_name) + if fullname is not None: + name = fullname + else: + assert first_name is not None and last_name is not None + name = '%s %s' % (first_name, last_name) name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii').lower() qs = self.filter(deleted__isnull=True) diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py index b871773c6..5dbdcd8e9 100644 --- a/src/authentic2/manager/forms.py +++ b/src/authentic2/manager/forms.py @@ -1,5 +1,5 @@ # authentic2 - versatile identity manager -# Copyright (C) 2010-2019 Entr'ouvert +# Copyright (C) 2010-2020 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 diff --git a/src/authentic2/manager/journal_event_types.py b/src/authentic2/manager/journal_event_types.py new file mode 100644 index 000000000..82459e991 --- /dev/null +++ b/src/authentic2/manager/journal_event_types.py @@ -0,0 +1,503 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2020 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.contrib.auth import get_user_model +from django.utils.translation import ugettext_lazy as _ + +from authentic2.journal_event_types import get_attributes_label, EventTypeWithService +from authentic2.apps.journal.models import EventTypeDefinition +from authentic2.apps.journal.utils import form_to_old_new + + +from django_rbac.utils import get_role_model + +User = get_user_model() +Role = get_role_model() + + +class ManagerUserCreation(EventTypeDefinition): + name = 'manager.user.creation' + label = _('user creation') + + @classmethod + def record(cls, user, session, form): + super().record(user=user, session=session, references=[form.instance]) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + # user journal page + if context and context == user: + return _('creation by administrator') + elif user: + # manager gloabal journal page + return _('creation of user "%s"') % user.get_full_name() + return super().get_message(event, context) + + +class ManagerUserProfileEdit(EventTypeDefinition): + name = 'manager.user.profile.edit' + 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)) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + new = event.get_data('new') or {} + edited_attributes = ', '.join(get_attributes_label(new)) or '' + if context and context == user: + return _('edit by administrator (%s)') % edited_attributes + elif user: + user_full_name = user.get_full_name() + return _('edit of user "{0}" ({1})').format(user_full_name, edited_attributes) + return super().get_message(event, context) + + +class ManagerUserEmailChangeRequest(EventTypeDefinition): + name = 'manager.user.email.change.request' + label = _('email change request') + + @classmethod + def record(cls, user, session, form): + data = { + 'old_email': form.instance.email, + 'email': form.cleaned_data.get('new_email'), + } + super().record(user=user, session=session, references=[form.instance], data=data) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + new_email = event.get_data('email') + if context and context == user: + return _('email change for email address "%s" requested by administrator') % new_email + elif user: + user_full_name = user.get_full_name() + return _('email change of user "{0}" for email address "{1}"').format(user_full_name, new_email) + return super().get_message(event, context) + + +class ManagerUserPasswordChange(EventTypeDefinition): + name = 'manager.user.password.change' + label = _('user password change') + + @classmethod + def record(cls, user, session, form): + data = { + 'generate_password': form.cleaned_data['generate_password'], + 'send_mail': form.cleaned_data['send_mail'], + } + super().record(user=user, session=session, references=[form.instance], data=data) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + send_mail = event.get_data('send_mail') + if context and context == user: + if send_mail: + return _('password change by administrator and notification by mail') + else: + return _('password change by administrator') + elif user: + user_full_name = user.get_full_name() + if send_mail: + return _('password change of user "%s" and notification by mail') % user_full_name + else: + return _('password change of user "%s"') % user_full_name + return super().get_message(event, context) + + +class ManagerUserPasswordResetRequest(EventTypeDefinition): + name = 'manager.user.password.reset.request' + label = _('user password reset request') + + @classmethod + def record(cls, user, session, target_user): + super().record( + user=user, session=session, references=[target_user], data={'email': target_user.email} + ) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + email = event.get_data('email') + if context and context == user: + return _('password reset request by administrator sent to "%s"') % email + elif user: + return _('password reset request of "{0}" sent to "{1}"').format(user.get_full_name(), email) + return super().get_message(event, context) + + +class ManagerUserPasswordChangeForce(EventTypeDefinition): + name = 'manager.user.password.change.force' + label = _('mandatory password change at next login set') + + @classmethod + def record(cls, user, session, target_user): + super().record(user=user, session=session, references=[target_user]) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + if context and context == user: + return _('mandatory password change at next login set by administrator') + elif user: + return _('mandatory password change at next login set for user "%s"') % user.get_full_name() + return super().get_message(event, context) + + +class ManagerUserPasswordChangeUnforce(EventTypeDefinition): + name = 'manager.user.password.change.unforce' + label = _('mandatory password change at next login unset') + + @classmethod + def record(cls, user, session, target_user): + super().record(user=user, session=session, references=[target_user]) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + if context and context == user: + return _('mandatory password change at next login unset by administrator') + elif user: + return _('mandatory password change at next login unset for user "%s"') % user.get_full_name() + return super().get_message(event, context) + + +class ManagerUserActivation(EventTypeDefinition): + name = 'manager.user.activation' + label = _('user activation') + + @classmethod + def record(cls, user, session, target_user): + super().record(user=user, session=session, references=[target_user]) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + if context and context == user: + return _('activation by administrator') + elif user: + return _('activation of user "%s"') % user.get_full_name() + return super().get_message(event, context) + + +class ManagerUserDeactivation(EventTypeDefinition): + name = 'manager.user.deactivation' + label = _('user deactivation') + + @classmethod + def record(cls, user, session, target_user): + super().record(user=user, session=session, references=[target_user]) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + if context and context == user: + return _('deactivation by administrator') + elif user: + return _('deactivation of user "%s"') % user.get_full_name() + return super().get_message(event, context) + + +class ManagerUserDeletion(EventTypeDefinition): + name = 'manager.user.deletion' + label = _('user deletion') + + @classmethod + def record(cls, user, session, target_user): + super().record(user=user, session=session, references=[target_user]) + + @classmethod + def get_message(cls, event, context): + (user,) = event.get_typed_references(User) + if context and context == user: + return _('deletion by administrator') + elif user: + return _('deletion of user "%s"') % user.get_full_name() + return super().get_message(event, context) + + +class ManagerUserSSOAuthorizationDeletion(EventTypeWithService): + name = 'manager.user.sso.authorization.deletion' + label = _('delete authorization') + + @classmethod + def record(cls, user, session, service, target_user): + super().record(user=user, session=session, service=service, references=[target_user]) + + @classmethod + def get_message(cls, event, context): + # first reference is to the service + __, user = event.get_typed_references(None, User) + service_name = cls.get_service_name(event) + if context and context == user: + return _('deletion of authorization of single sign on with "%s" by administrator') % service_name + elif user: + return _('deletion of authorization of single sign on with "%s" of user "%s"') % ( + service_name, + user.get_full_name(), + ) + return super().get_message(event, context) + + +class RoleEventsMixin(EventTypeDefinition): + @classmethod + def record(self, user, session, role, references=None, data=None): + references = references or [] + references = [role] + references + data = data or {} + data.update( + {'role_name': str(role), 'role_uuid': role.uuid,} + ) + super().record( + user=user, session=session, references=references, data=data, + ) + + +class ManagerRoleCreation(RoleEventsMixin): + name = 'manager.role.creation' + label = _('role creation') + + @classmethod + def get_message(cls, event, context): + (role,) = event.get_typed_references(Role) + role = role or event.get_data('role_name') + if context != role: + return _('creation of role "%s"') % role + else: + return _('creation') + + +class ManagerRoleEdit(RoleEventsMixin): + name = 'manager.role.edit' + 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)) + + @classmethod + def get_message(cls, event, context): + (role,) = event.get_typed_references(Role) + role = role or event.get_data('role_name') + new = event.get_data('new') + edited_attributes = ', '.join(get_attributes_label(new)) or '' + if context != role: + return _('edit of role "%s" (%s)') % (role, edited_attributes) + else: + return _('edit (%s)') % edited_attributes + + +class ManagerRoleDeletion(RoleEventsMixin): + name = 'manager.role.deletion' + label = _('role deletion') + + @classmethod + def get_message(cls, event, context): + (role,) = event.get_typed_references(Role) + role = role or event.get_data('role_name') + if context != role: + return _('deletion of role "%s"') % role + else: + return _('deletion') + + +class ManagerRoleMembershipGrant(RoleEventsMixin): + name = 'manager.role.membership.grant' + label = _('role membership grant') + + @classmethod + def record(cls, user, session, role, member): + data = {'member_name': member.get_full_name()} + super().record(user=user, session=session, role=role, references=[member], data=data) + + @classmethod + def get_message(cls, event, context): + role, member = event.get_typed_references(Role, User) + role = role or event.get_data('role_name') + member = member or event.get_data('member_name') + if context == member: + return _('membership grant in role "%s"') % role + elif context == role: + return _('membership grant to user "%s"') % member + else: + return _('membership grant to user "{member}" in role "{role}"').format(member=member, role=role) + + +class ManagerRoleMembershipRemoval(RoleEventsMixin): + name = 'manager.role.membership.removal' + label = _('role membership removal') + + @classmethod + def record(cls, user, session, role, member): + data = {'member_name': member.get_full_name()} + super().record(user=user, session=session, role=role, references=[member], data=data) + + @classmethod + def get_message(cls, event, context): + role, member = event.get_typed_references(Role, User) + role = role or event.get_data('role_name') + member = member or event.get_data('member_name') + if context == member: + return _('membership removal from role "%s"') % role + elif context == role: + return _('membership removal of user "%s"') % member + else: + return _('membership removal of user "{member}" from role "{role}"').format( + member=member, role=role + ) + + +class ManagerRoleInheritanceAddition(RoleEventsMixin): + name = 'manager.role.inheritance.addition' + label = _('role inheritance addition') + + @classmethod + def record(cls, user, session, parent, child): + data = { + 'child_name': str(child), + 'child_uuid': child.uuid, + } + super().record(user=user, session=session, role=parent, references=[child], data=data) + + @classmethod + def get_message(cls, event, context): + parent, child = event.get_typed_references(Role, Role) + parent = parent or event.get_data('role_name') + child = child or event.get_data('child_name') + if context == child: + return _('inheritance addition from parent role "%s"') % parent + elif context == parent: + return _('inheritance addition to child role "%s"') % child + else: + return _('inheritance addition from parent role "{parent}" to child role "{child}"').format( + parent=parent, child=child + ) + + +class ManagerRoleInheritanceRemoval(ManagerRoleInheritanceAddition): + name = 'manager.role.inheritance.removal' + label = _('role inheritance removal') + + @classmethod + def get_message(cls, event, context): + parent, child = event.get_typed_references(Role, Role) + parent = parent or event.get_data('role_name') + child = child or event.get_data('child_name') + if context == child: + return _('inheritance removal from parent role "%s"') % parent + elif context == parent: + return _('inheritance removal to child role "%s"') % child + else: + return _('inheritance removal from parent role "{parent}" to child role "{child}"').format( + parent=parent, child=child + ) + + +class ManagerRoleAdministratorRoleAddition(RoleEventsMixin): + name = 'manager.role.administrator.role.addition' + label = _('role administrator role addition') + + @classmethod + def record(cls, user, session, role, admin_role): + data = { + 'admin_role_name': str(admin_role), + 'admin_role_uuid': admin_role.uuid, + } + super().record(user=user, session=session, role=role, references=[admin_role], data=data) + + @classmethod + def get_message(cls, event, context): + role, admin_role = event.get_typed_references(Role, Role) + role = role or event.get_data('role_name') + admin_role = admin_role or event.get('admin_role_name') + if context == role: + return _('addition of role "%s" as administrator') % admin_role + elif context == admin_role: + return _('addition as administrator of role "%s"') % role + else: + return _('addition of role "{admin_role}" as administrator of role "{role}"').format( + admin_role=admin_role, role=role + ) + + +class ManagerRoleAdministratorRoleRemoval(ManagerRoleAdministratorRoleAddition): + name = 'manager.role.administrator.role.removal' + label = _('role administrator role removal') + + @classmethod + def get_message(cls, event, context): + role, admin_role = event.get_typed_references(Role, Role) + role = role or event.get_data('role_name') + admin_role = admin_role or event.get('admin_role_name') + if context == role: + return _('removal of role "%s" as administrator') % admin_role + elif context == admin_role: + return _('removal as administrator of role "%s"') % role + else: + return _('removal of role "{admin_role}" as administrator of role "{role}"').format( + admin_role=admin_role, role=role + ) + + +class ManagerRoleAdministratorUserAddition(RoleEventsMixin): + name = 'manager.role.administrator.user.addition' + label = _('role administrator user addition') + + @classmethod + def record(cls, user, session, role, admin_user): + data = { + 'admin_user_name': admin_user.get_full_name(), + 'admin_user_uuid': admin_user.uuid, + } + super().record(user=user, session=session, role=role, references=[admin_user], data=data) + + @classmethod + def get_message(cls, event, context): + role, admin_user = event.get_typed_references(Role, User) + role = role or event.get_data('role_name') + admin_user = admin_user or event.get_data('admin_user_name') + if context == role: + return _('addition of user "%s" as administrator') % admin_user + elif context == admin_user: + return _('addition as administrator of role "%s"') % role + else: + return _('addition of user "{admin_user}" as administrator of role "{role}"').format( + admin_user=admin_user, role=role + ) + + +class ManagerRoleAdministratorUserRemoval(ManagerRoleAdministratorUserAddition): + name = 'manager.role.administrator.user.removal' + label = _('role administrator user removal') + + @classmethod + def get_message(cls, event, context): + role, admin_user = event.get_typed_references(Role, User) + role = role or event.get_data('role_name') + admin_user = admin_user or event.get_data('admin_user_name') + if context == role: + return _('removal of user "%s" as administrator') % admin_user + elif context == admin_user: + return _('removal as administrator of role "%s"') % role + else: + return _('removal of user "{admin_user}" as administrator of role "{role}"').format( + admin_user=admin_user, role=role + ) diff --git a/src/authentic2/manager/journal_views.py b/src/authentic2/manager/journal_views.py new file mode 100644 index 000000000..e764db2ed --- /dev/null +++ b/src/authentic2/manager/journal_views.py @@ -0,0 +1,98 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2020 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 uuid + +from django.contrib.auth import get_user_model +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.validators import EmailValidator +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ + +from authentic2.apps.journal.forms import JournalForm +from authentic2.apps.journal.search_engine import JournalSearchEngine +from authentic2.apps.journal.views import JournalView + +from . import views + +User = get_user_model() + + +class JournalSearchEngine(JournalSearchEngine): + def search_by_uuid(self, lexem): + # by user uuid + try: + user_uuid = uuid.UUID(lexem) + except ValueError: + yield self.q_false + else: + yield Q(user__uuid=user_uuid.hex) + + search_by_uuid.documentation = _( + '''\ +You can use uuid:1234 to find all events related \ +to user whose UUID is 1234.''' + ) + unmatched = None + + def lexem_queries(self, lexem): + queries = list(super().lexem_queries(lexem)) + if queries: + yield from queries + elif '@' in lexem: + # fallback for raw email + try: + EmailValidator(lexem) + except ValidationError: + pass + else: + yield from super().lexem_queries('email:' + lexem) + yield from super().lexem_queries('username:' + lexem) + + def unmatched_lexems_query(self, lexems): + fullname = ' '.join(lexem.strip() for lexem in lexems if lexem.strip()) + if fullname: + users = User.objects.find_duplicates(fullname=fullname) + return self.query_for_users(users) + + +class JournalForm(JournalForm): + search_engine_class = JournalSearchEngine + + +class BaseJournalView(views.TitleMixin, views.MediaMixin, views.MultipleOUMixin, JournalView): + template_name = 'authentic2/manager/journal.html' + title = _('Journal') + form_class = JournalForm + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + date_hierarchy = ctx['date_hierarchy'] + if date_hierarchy.title: + ctx['title'] = _('Journal of %s') % date_hierarchy.title + return ctx + + +class GlobalJournalView(BaseJournalView): + template_name = 'authentic2/manager/journal.html' + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_superuser: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +journal = GlobalJournalView.as_view() diff --git a/src/authentic2/manager/role_views.py b/src/authentic2/manager/role_views.py index 22ba3361d..bba2dbca3 100644 --- a/src/authentic2/manager/role_views.py +++ b/src/authentic2/manager/role_views.py @@ -17,6 +17,7 @@ import json from django.core.exceptions import PermissionDenied, ValidationError +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django.urls import reverse from django.views.generic import FormView, TemplateView @@ -27,15 +28,20 @@ from django.db import transaction from django.db.models.query import Q, Prefetch from django.db.models import Count, F from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 from django_rbac.utils import get_role_model, get_permission_model, get_ou_model from authentic2.forms.profile import modelform_factory from authentic2.utils import redirect from authentic2 import hooks, data_transfer +from authentic2.apps.journal.views import JournalViewWithContext from . import tables, views, resources, forms, app_settings from .utils import has_show_username +from .journal_views import BaseJournalView + +OU = get_ou_model() class RolesMixin(object): @@ -48,7 +54,7 @@ class RolesMixin(object): Permission = get_permission_model() permission_ct = ContentType.objects.get_for_model(Permission) ct_ct = ContentType.objects.get_for_model(ContentType) - ou_ct = ContentType.objects.get_for_model(get_ou_model()) + ou_ct = ContentType.objects.get_for_model(OU) permission_qs = Permission.objects.filter(target_ct_id__in=[ct_ct.id, ou_ct.id]) \ .values_list('id', flat=True) # only non role-admin roles, they are accessed through the @@ -61,7 +67,8 @@ class RolesMixin(object): return qs -class RolesView(views.HideOUColumnMixin, RolesMixin, views.BaseTableView): +class RolesView(views.SearchOUMixin, views.HideOUColumnMixin, RolesMixin, + views.BaseTableView): template_name = 'authentic2/manager/roles.html' model = get_role_model() table_class = tables.RoleTable @@ -105,6 +112,7 @@ class RoleAddView(views.BaseAddView): response = super(RoleAddView, self).form_valid(form) hooks.call_hooks('event', name='manager-add-role', user=self.request.user, instance=form.instance, form=form) + self.request.journal.record('manager.role.creation', role=form.instance) return response @@ -145,6 +153,7 @@ class RoleEditView(RoleViewMixin, views.BaseEditView): response = super(RoleEditView, self).form_valid(form) hooks.call_hooks('event', name='manager-edit-role', user=self.request.user, instance=form.instance, form=form) + self.request.journal.record('manager.role.edit', role=form.instance, form=form) return response edit = RoleEditView.as_view() @@ -186,6 +195,7 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable self.object.members.add(user) hooks.call_hooks('event', name='manager-add-role-member', user=self.request.user, role=self.object, member=user) + self.request.journal.record('manager.role.membership.grant', role=self.object, member=user) elif action == 'remove': if not self.object.members.filter(pk=user.pk).exists(): messages.warning(self.request, _('User was not in this role.')) @@ -193,6 +203,7 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable self.object.members.remove(user) hooks.call_hooks('event', name='manager-remove-role-member', user=self.request.user, role=self.object, member=user) + self.request.journal.record('manager.role.membership.removal', role=self.object, member=user) else: messages.warning(self.request, _('You are not authorized')) return super(RoleMembersView, self).form_valid(form) @@ -210,7 +221,7 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable self.object.children(include_self=False, annotate=True)) ctx['parents'] = views.filter_view(self.request, self.object.parents( include_self=False, annotate=True).order_by(F('ou').asc(nulls_first=True), 'name')) - ctx['has_multiple_ou'] = get_ou_model().objects.count() > 1 + ctx['has_multiple_ou'] = OU.objects.count() > 1 ctx['admin_roles'] = views.filter_view(self.request, self.object.get_admin_role().children(include_self=False, annotate=True)) @@ -247,11 +258,12 @@ class RoleDeleteView(RoleViewMixin, views.BaseDeleteView): def get_success_url(self): return reverse('a2-manager-roles') - def form_valid(self, form): - response = super(RoleDeleteView, self).form_valid(form) - hooks.call_hooks('event', name='manager-delete-role', user=self.request.user, - role=form.instance) - return response + def delete(self, request, *args, **kwargs): + role = self.get_object() + + hooks.call_hooks('event', name='manager-delete-role', user=request.user, role=role) + self.request.journal.record('manager.role.deletion', role=role) + return super(RoleDeleteView, self).delete(request, *args, **kwargs) delete = RoleDeleteView.as_view() @@ -331,6 +343,7 @@ class RoleAddChildView(views.AjaxFormViewMixin, views.TitleMixin, parent.add_child(role) hooks.call_hooks('event', name='manager-add-child-role', user=self.request.user, parent=parent, child=role) + self.request.journal.record('manager.role.inheritance.addition', parent=parent, child=role) return super(RoleAddChildView, self).form_valid(form) add_child = RoleAddChildView.as_view() @@ -356,6 +369,7 @@ class RoleAddParentView(views.AjaxFormViewMixin, views.TitleMixin, child.add_parent(role) hooks.call_hooks('event', name='manager-add-child-role', user=self.request.user, parent=role, child=child) + self.request.journal.record('manager.role.inheritance.addition', parent=role, child=child) return super(RoleAddParentView, self).form_valid(form) add_parent = RoleAddParentView.as_view() @@ -383,6 +397,7 @@ class RoleRemoveChildView(views.AjaxFormViewMixin, SingleObjectMixin, self.object.remove_child(self.child) hooks.call_hooks('event', name='manager-remove-child-role', user=self.request.user, parent=self.object, child=self.child) + self.request.journal.record('manager.role.inheritance.removal', parent=self.object, child=self.child) return redirect(self.request, self.success_url) remove_child = RoleRemoveChildView.as_view() @@ -413,6 +428,7 @@ class RoleRemoveParentView(views.AjaxFormViewMixin, SingleObjectMixin, self.object.remove_parent(self.parent) hooks.call_hooks('event', name='manager-remove-child-role', user=self.request.user, parent=self.parent, child=self.object) + self.request.journal.record('manager.role.inheritance.removal', parent=self.parent, child=self.object) return redirect(self.request, self.success_url) remove_parent = RoleRemoveParentView.as_view() @@ -438,6 +454,8 @@ class RoleAddAdminRoleView(views.AjaxFormViewMixin, views.TitleMixin, administered_role.get_admin_role().add_child(role) hooks.call_hooks('event', name='manager-add-admin-role', user=self.request.user, role=administered_role, admin_role=role) + self.request.journal.record('manager.role.administrator.role.addition', + role=administered_role, admin_role=role) return super(RoleAddAdminRoleView, self).form_valid(form) add_admin_role = RoleAddAdminRoleView.as_view() @@ -466,6 +484,8 @@ class RoleRemoveAdminRoleView(views.TitleMixin, views.AjaxFormViewMixin, self.object.get_admin_role().remove_child(self.child) hooks.call_hooks('event', name='manager-remove-admin-role', user=self.request.user, role=self.object, admin_role=self.child) + self.request.journal.record('manager.role.administrator.role.removal', + role=self.object, admin_role=self.child) return redirect(self.request, self.success_url) remove_admin_role = RoleRemoveAdminRoleView.as_view() @@ -491,6 +511,8 @@ class RoleAddAdminUserView(views.AjaxFormViewMixin, views.TitleMixin, administered_role.get_admin_role().members.add(user) hooks.call_hooks('event', name='manager-add-admin-role-user', user=self.request.user, role=administered_role, admin=user) + self.request.journal.record('manager.role.administrator.user.addition', + role=administered_role, admin_user=user) return super(RoleAddAdminUserView, self).form_valid(form) add_admin_user = RoleAddAdminUserView.as_view() @@ -519,6 +541,8 @@ class RoleRemoveAdminUserView(views.TitleMixin, views.AjaxFormViewMixin, self.object.get_admin_role().members.remove(self.user) hooks.call_hooks('event', name='remove-remove-admin-role-user', user=self.request.user, role=self.object, admin=self.user) + self.request.journal.record('manager.role.administrator.user.removal', + role=self.object, admin_user=self.user) return redirect(self.request, self.success_url) remove_admin_user = RoleRemoveAdminUserView.as_view() @@ -564,3 +588,33 @@ class RolesImportView(views.PermissionMixin, views.TitleMixin, views.MediaMixin, roles_import = RolesImportView.as_view() + + +class RoleJournal(views.PermissionMixin, JournalViewWithContext, BaseJournalView): + template_name = 'authentic2/manager/role_journal.html' + permissions = ['a2_rbac.view_role'] + title = _('Journal') + + @cached_property + def context(self): + return get_object_or_404(get_role_model(), pk=self.kwargs['pk']) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['object'] = self.context + return ctx + +journal = RoleJournal.as_view() + + +class RolesJournal(views.SearchOUMixin, views.PermissionMixin, JournalViewWithContext, BaseJournalView): + template_name = 'authentic2/manager/roles_journal.html' + permissions = ['a2_rbac.view_role'] + title = _('Journal') + + @cached_property + def context(self): + return get_role_model() + + +roles_journal = RolesJournal.as_view() diff --git a/src/authentic2/manager/static/authentic2/manager/js/manager.js b/src/authentic2/manager/static/authentic2/manager/js/manager.js index 0afdf65a6..7a879fd94 100644 --- a/src/authentic2/manager/static/authentic2/manager/js/manager.js +++ b/src/authentic2/manager/static/authentic2/manager/js/manager.js @@ -219,5 +219,20 @@ window.history.replaceState({'form': '#search-form', 'values': $('#search-form').values()}, window.document.title, window.location.href) } $(window.document).trigger('gadjo:content-update'); + function FitToContent(id, maxHeight) + { + var text = id && id.style ? id : document.getElementById(id); + if ( !text ) + return; + + } + $('textarea.js-autoresize').each(function () { + this.setAttribute('style', 'height:' + (this.scrollHeight) + 'px;overflow-y:hidden;'); + }) + $(document).on('input', 'textarea.js-autoresize', function () { + this.style.height = 'auto'; + this.style.height = (this.scrollHeight) + 'px'; + return true; + }); }); })(jQuery, window) diff --git a/src/authentic2/manager/templates/authentic2/manager/journal.html b/src/authentic2/manager/templates/authentic2/manager/journal.html new file mode 100644 index 000000000..8d49d0eb5 --- /dev/null +++ b/src/authentic2/manager/templates/authentic2/manager/journal.html @@ -0,0 +1,43 @@ +{% extends "authentic2/manager/base.html" %} +{% load i18n gadjo %} + +{% block page-title %}{{ block.super }} - {% trans "Journal" %}{% endblock %} + +{% block breadcrumb %} + {{ block.super }} + {% block breadcrumb-before-title %} + {% endblock %} + {% trans 'Journal' %} + {% for caption, url in date_hierarchy.back_urls %} + {{ caption }} + {% endfor %} +{% endblock %} + +{% block sidebar %} + +{% endblock %} + +{% block main %} + {% include "journal/event_list.html" %} +{% endblock %} diff --git a/src/authentic2/manager/templates/authentic2/manager/role_common.html b/src/authentic2/manager/templates/authentic2/manager/role_common.html index 245a6956b..24e2b3ac2 100644 --- a/src/authentic2/manager/templates/authentic2/manager/role_common.html +++ b/src/authentic2/manager/templates/authentic2/manager/role_common.html @@ -6,4 +6,8 @@ {% block breadcrumb %} {{ block.super }} {% trans 'Roles' %} + {% firstof ou object.ou as current_ou %} + {% if multiple_ou and current_ou %} + {{ current_ou }} + {% endif %} {% endblock %} diff --git a/src/authentic2/manager/templates/authentic2/manager/role_journal.html b/src/authentic2/manager/templates/authentic2/manager/role_journal.html new file mode 100644 index 000000000..d3fbb4c2f --- /dev/null +++ b/src/authentic2/manager/templates/authentic2/manager/role_journal.html @@ -0,0 +1,10 @@ +{% extends "authentic2/manager/journal.html" %} +{% load i18n gadjo %} + +{% block breadcrumb-before-title %} + {% trans 'Roles' %} + {% if multiple_ou and object.ou %} + {{ object.ou }} + {% endif %} + {{ object }} +{% endblock %} diff --git a/src/authentic2/manager/templates/authentic2/manager/role_members.html b/src/authentic2/manager/templates/authentic2/manager/role_members.html index 37177cd07..03ef96250 100644 --- a/src/authentic2/manager/templates/authentic2/manager/role_members.html +++ b/src/authentic2/manager/templates/authentic2/manager/role_members.html @@ -3,9 +3,6 @@ {% block breadcrumb %} {{ block.super }} - {% if multiple_ou and object.ou %} - {{ object.ou }} - {% endif %} {{ object }} {% endblock %} @@ -19,6 +16,7 @@ {% block appbar %} {{ block.super }} + {% if not object.is_internal and view.can_delete %} {% trans "Delete" %} {% else %} @@ -36,6 +34,9 @@ {% if perms.a2_rbac.admin_permission %} {% trans "Permissions" %} {% endif %} + {% endblock %} diff --git a/src/authentic2/manager/templates/authentic2/manager/roles.html b/src/authentic2/manager/templates/authentic2/manager/roles.html index 3aa539896..9cc1c9ac6 100644 --- a/src/authentic2/manager/templates/authentic2/manager/roles.html +++ b/src/authentic2/manager/templates/authentic2/manager/roles.html @@ -3,6 +3,8 @@ {% block page-title %}{{ block.super }} - {% trans "Roles" %}{% endblock %} +{% block page_title %}{% if multiple_ou and ou %}{{ ou }}{% else %}{{ block.super }}{% endif %}{% endblock %} + {% block appbar %} {{ block.super }} @@ -13,6 +15,7 @@ {% trans "Add role" %} {% endif %}
    +
  • {% trans "Journal" %}
  • {% trans 'Export' %}
  • {% if view.can_add %}
  • {% trans 'Import' %}
  • diff --git a/src/authentic2/manager/templates/authentic2/manager/roles_journal.html b/src/authentic2/manager/templates/authentic2/manager/roles_journal.html new file mode 100644 index 000000000..f8a56fac7 --- /dev/null +++ b/src/authentic2/manager/templates/authentic2/manager/roles_journal.html @@ -0,0 +1,9 @@ +{% extends "authentic2/manager/journal.html" %} +{% load i18n gadjo %} + +{% block breadcrumb-before-title %} + {% trans 'Roles' %} + {% if multiple_ou and ou %} + {{ ou }} + {% endif %} +{% endblock %} diff --git a/src/authentic2/manager/templates/authentic2/manager/user_detail.html b/src/authentic2/manager/templates/authentic2/manager/user_detail.html index b3b7a0396..1de771322 100644 --- a/src/authentic2/manager/templates/authentic2/manager/user_detail.html +++ b/src/authentic2/manager/templates/authentic2/manager/user_detail.html @@ -22,6 +22,7 @@ {% if view.is_oidc_services %}
  • {% trans "Consents" %}
  • {% endif %} +
  • {% trans "Journal" %}
{% endblock %} diff --git a/src/authentic2/manager/templates/authentic2/manager/user_journal.html b/src/authentic2/manager/templates/authentic2/manager/user_journal.html new file mode 100644 index 000000000..894d3a411 --- /dev/null +++ b/src/authentic2/manager/templates/authentic2/manager/user_journal.html @@ -0,0 +1,10 @@ +{% extends "authentic2/manager/journal.html" %} +{% load i18n gadjo %} + +{% block breadcrumb-before-title %} + {% trans 'Users' %} + {% if multiple_ou and object.ou %} + {{ object.ou }} + {% endif %} + {{ object.get_full_name }} +{% endblock %} diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py index 5dbd5b6e4..f6d4eb33c 100644 --- a/src/authentic2/manager/urls.py +++ b/src/authentic2/manager/urls.py @@ -19,7 +19,7 @@ from django.conf.urls import url from django.views.i18n import JavaScriptCatalog from django.contrib.auth.decorators import login_required from django.utils.functional import lazy -from . import views, role_views, ou_views, user_views, service_views +from . import views, role_views, ou_views, user_views, service_views, journal_views from ..decorators import required from authentic2 import utils @@ -67,6 +67,12 @@ urlpatterns = required( name='a2-manager-user-change-email'), url(r'^users/(?P\d+)/su/$', user_views.su, name='a2-manager-user-su'), + url(r'^users/(?P\d+)/authorizations/$', + user_views.user_authorizations, + name='a2-manager-user-authorizations'), + url(r'^users/(?P\d+)/journal/$', + user_views.user_journal, + name='a2-manager-user-journal'), # by uuid url(r'^users/uuid:(?P[a-z0-9]+)/$', user_views.user_detail, name='a2-manager-user-by-uuid-detail'), @@ -81,9 +87,9 @@ urlpatterns = required( url(r'^users/uuid:(?P[a-z0-9]+)/change-email/$', user_views.user_change_email, name='a2-manager-user-by-uuid-change-email'), - url(r'^users/(?P\d+)/authorizations/$', - user_views.user_authorizations, - name='a2-manager-user-authorizations'), + url(r'^users/uuid:(?P[a-z0-9]+)/journal/$', + user_views.user_journal, + name='a2-manager-user-journal'), # Authentic2 roles url(r'^roles/$', role_views.listing, @@ -94,6 +100,8 @@ urlpatterns = required( name='a2-manager-role-add'), url(r'^roles/export/(?Pcsv|json)/$', role_views.export, name='a2-manager-roles-export'), + url(r'^roles/journal/$', role_views.roles_journal, + name='a2-manager-roles-journal'), url(r'^roles/(?P\d+)/$', role_views.members, name='a2-manager-role-members'), url(r'^roles/(?P\d+)/add-child/$', role_views.add_child, @@ -124,6 +132,8 @@ urlpatterns = required( name='a2-manager-role-edit'), url(r'^roles/(?P\d+)/permissions/$', role_views.permissions, name='a2-manager-role-permissions'), + url(r'^roles/(?P\d+)/journal/$', role_views.journal, + name='a2-manager-role-journal'), # Authentic2 organizational units @@ -152,6 +162,10 @@ urlpatterns = required( url(r'^services/(?P\d+)/edit/$', service_views.edit, name='a2-manager-service-edit'), + # Journal + url(r'^journal/$', journal_views.journal, + name='a2-manager-journal'), + # backoffice menu as json url(r'^menu.json$', views.menu_json), diff --git a/src/authentic2/manager/user_views.py b/src/authentic2/manager/user_views.py index 78d9fb79c..a6826d928 100644 --- a/src/authentic2/manager/user_views.py +++ b/src/authentic2/manager/user_views.py @@ -20,6 +20,7 @@ import collections import operator from django.db import models +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _, pgettext_lazy, ugettext from django.utils.html import format_html from django.urls import reverse @@ -33,6 +34,7 @@ from django.views.generic import FormView, TemplateView, DetailView from django.views.generic.edit import BaseFormView from django.views.generic.detail import SingleObjectMixin from django.http import Http404, FileResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404 import tablib @@ -41,8 +43,9 @@ from authentic2.utils import send_password_reset_mail, redirect, select_next_url from authentic2.a2_rbac.utils import get_default_ou from authentic2 import hooks from authentic2_idp_oidc.models import OIDCAuthorization, OIDCClient -from django_rbac.utils import get_role_model, get_role_parenting_model, get_ou_model +from authentic2.apps.journal.views import JournalViewWithContext +from django_rbac.utils import get_role_model, get_role_parenting_model, get_ou_model from .views import (BaseTableView, BaseAddView, BaseEditView, ActionMixin, OtherActionsMixin, Action, ExportMixin, BaseSubTableView, @@ -55,6 +58,7 @@ from .forms import (UserSearchForm, UserAddForm, UserEditForm, UserEditImportForm, ChooseUserAuthorizationsForm) from .resources import UserResource from .utils import get_ou_count, has_show_username +from .journal_views import BaseJournalView from . import app_settings User = get_user_model() @@ -215,6 +219,7 @@ class UserAddView(BaseAddView): response = super(UserAddView, self).form_valid(form) hooks.call_hooks('event', name='manager-add-user', user=self.request.user, instance=form.instance, form=form) + self.request.journal.record('manager.user.creation', form=form) return response def get_initial(self, *args, **kwargs): @@ -286,10 +291,12 @@ class UserDetailView(OtherActionsMixin, BaseDetailView): def action_force_password_change(self, request, *args, **kwargs): PasswordReset.objects.get_or_create(user=self.object) + request.journal.record('manager.user.password.change.force', target_user=self.object) def action_activate(self, request, *args, **kwargs): self.object.is_active = True self.object.save() + request.journal.record('manager.user.activation', target_user=self.object) def action_deactivate(self, request, *args, **kwargs): if request.user == self.object: @@ -298,6 +305,7 @@ class UserDetailView(OtherActionsMixin, BaseDetailView): else: self.object.is_active = False self.object.save() + request.journal.record('manager.user.deactivation', target_user=self.object) def action_password_reset(self, request, *args, **kwargs): user = self.object @@ -308,9 +316,11 @@ class UserDetailView(OtherActionsMixin, BaseDetailView): return send_password_reset_mail(user, request=request) messages.info(request, _('A mail was sent to %s') % self.object.email) + request.journal.record('manager.user.password.reset.request', target_user=self.object) def action_delete_password_reset(self, request, *args, **kwargs): PasswordReset.objects.filter(user=self.object).delete() + request.journal.record('manager.user.password.change.unforce', target_user=self.object) def action_su(self, request, *args, **kwargs): return redirect(request, 'auth_logout', @@ -430,8 +440,10 @@ class UserEditView(OtherActionsMixin, ActionMixin, BaseEditView): self.object.email_verified = False self.object.save() response = super(UserEditView, self).form_valid(form) - hooks.call_hooks('event', name='manager-edit-user', user=self.request.user, - instance=form.instance, form=form) + if form.has_changed(): + hooks.call_hooks('event', name='manager-edit-user', user=self.request.user, + instance=form.instance, form=form) + self.request.journal.record('manager.user.profile.edit', form=form) return response user_edit = UserEditView.as_view() @@ -518,6 +530,7 @@ class UserChangePasswordView(BaseEditView): response = super(UserChangePasswordView, self).form_valid(form) hooks.call_hooks('event', name='manager-change-password', user=self.request.user, instance=form.instance, form=form) + self.request.journal.record('manager.user.password.change', form=form) return response @@ -633,10 +646,13 @@ class UserRolesView(HideOUColumnMixin, BaseSubTableView): user.roles.add(role) hooks.call_hooks('event', name='manager-add-role-member', user=self.request.user, role=role, member=user) + self.request.journal.record('manager.role.membership.grant', member=user, role=role) elif action == 'remove': - user.roles.remove(role) - hooks.call_hooks('event', name='manager-remove-role-member', user=self.request.user, - role=role, member=user) + if user.roles.filter(pk=role.pk).exists(): + user.roles.remove(role) + hooks.call_hooks('event', name='manager-remove-role-member', user=self.request.user, + role=role, member=user) + self.request.journal.record('manager.role.membership.removal', member=user, role=role) return super(UserRolesView, self).form_valid(form) def get_search_form_kwargs(self): @@ -674,6 +690,7 @@ class UserDeleteView(BaseDeleteView): self.get_object().mark_as_deleted() hooks.call_hooks('event', name='manager-delete-user', user=request.user, instance=self.object) + request.journal.record('manager.user.deletion', target_user=self.object) return HttpResponseRedirect(self.get_success_url()) @@ -898,8 +915,32 @@ class UserAuthorizationsView(FormNeedsRequest, BaseFormView, SingleObjectMixin, if self.can_manage_authorizations: qs = OIDCAuthorization.objects.filter(user=self.get_object()) qs = qs.filter(id=auth_id.pk) - qs.delete() + oidc_authorization = qs.first() + count, cascade = qs.delete() + if count: + self.request.journal.record( + 'manager.user.sso.authorization.deletion', + service=oidc_authorization.client, + target_user=self.object) return response user_authorizations = UserAuthorizationsView.as_view() + + +class UserJournal(PermissionMixin, JournalViewWithContext, BaseJournalView): + template_name = 'authentic2/manager/user_journal.html' + permissions = ['custom_user.view_user'] + title = _('Journal') + + @cached_property + def context(self): + return get_object_or_404(User, pk=self.kwargs['pk']) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['object'] = self.context + return ctx + + +user_journal = UserJournal.as_view() diff --git a/src/authentic2/manager/views.py b/src/authentic2/manager/views.py index 33aa38784..31c953bc7 100644 --- a/src/authentic2/manager/views.py +++ b/src/authentic2/manager/views.py @@ -16,7 +16,7 @@ import base64 import json -import inspect +import itertools import pickle from django.core import signing @@ -30,6 +30,7 @@ from django.views.generic.edit import FormMixin from django.http import HttpResponse, Http404 from django.utils.encoding import force_text from django.utils import six +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django.utils.timezone import now from django.urls import reverse @@ -45,6 +46,7 @@ from gadjo.templatetags.gadjo import xstatic from django_rbac.utils import get_ou_model +from authentic2.a2_rbac.models import OrganizationalUnit as OU from authentic2.data_transfer import export_site, import_site, ImportContext from authentic2.forms.profile import modelform_factory from authentic2.utils import redirect, batch_queryset @@ -597,6 +599,14 @@ class HomepageView(TitleMixin, PermissionMixin, MediaMixin, TemplateView): 'permission': 'authentic2.search_service', 'slug': 'services', }, + { + 'class': 'icon-journal', + 'href': reverse_lazy('a2-manager-journal'), + 'label': _('Journal'), + 'order': -1, + 'permission': 'superuser', + 'slug': 'journal', + }, ] def dispatch(self, request, *args, **kwargs): @@ -606,15 +616,16 @@ class HomepageView(TitleMixin, PermissionMixin, MediaMixin, TemplateView): def get_homepage_entries(self): entries = [] - for entry in self.default_entries: - if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']): - continue - entries.append(entry) - for hook_entries in hooks.call_hooks('manager_homepage_entries', self): + for hook_entries in itertools.chain( + self.default_entries, + hooks.call_hooks('manager_homepage_entries', self)): if not hasattr(hook_entries, 'append'): hook_entries = [hook_entries] for entry in hook_entries: - if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']): + permission = entry.get('permission') + if permission == 'superuser' and not self.request.user.is_superuser: + continue + elif permission and not self.request.user.has_perm_any(permission): continue entries.append(entry) # use possible key order to sort @@ -730,3 +741,17 @@ class SiteImportView(MediaMixin, TitleMixin, FormView): site_import = SiteImportView.as_view() + + +class SearchOUMixin: + @cached_property + def ou(self): + try: + ou_id = int(self.request.GET['search-ou']) + except (ValueError, KeyError): + return None + else: + return OU.objects.filter(pk=ou_id).first() + + def get_context_data(self, **kwargs): + return super().get_context_data(ou=self.ou, **kwargs) diff --git a/tests/test_manager_journal.py b/tests/test_manager_journal.py new file mode 100644 index 000000000..6c2ea0a7e --- /dev/null +++ b/tests/test_manager_journal.py @@ -0,0 +1,892 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2020 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 datetime +import mock + +from authentic2.custom_user.models import User +from authentic2.a2_rbac.utils import get_default_ou +from authentic2.a2_rbac.models import Role +from authentic2.models import Service +from authentic2.apps.journal.models import Event, _registry +from authentic2.journal import journal + +from django.contrib.sessions.models import Session +from django.utils.timezone import make_aware + +import pytest + +from .utils import login, text_content + + +def test_journal_authorization(app, db, admin): + response = login(app, admin, path='/manage/') + assert 'Journal' not in response + app.get('/manage/journal/', status=403) + + +@pytest.fixture(autouse=True) +def events(db, freezer): + session1 = Session(session_key="1234") + session2 = Session(session_key="abcd") + + ou = get_default_ou() + user = User.objects.create( + username="user", email="user@example.com", ou=ou, uuid="1" * 32, first_name='Johnny', last_name='doe' + ) + agent = User.objects.create(username="agent", email="agent@example.com", ou=ou, uuid="2" * 32) + role_user = Role.objects.create(name="role1", ou=ou) + role_agent = Role.objects.create(name="role2", ou=ou) + service = Service.objects.create(name="service") + + class EventFactory: + date = make_aware(datetime.datetime(2020, 1, 1)) + + def __call__(self, name, **kwargs): + freezer.move_to(self.date) + journal.record(name, **kwargs) + assert Event.objects.latest("timestamp").type.name == name + self.date += datetime.timedelta(hours=1) + + make = EventFactory() + make("user.registration.request", email=user.email) + make( + "user.registration", user=user, session=session1, service=service, how="franceconnect", + ) + make("user.logout", user=user, session=session1) + + make("user.login.failure", username="user") + 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.initial = {'email': "user@example.com", 'first_name': "John"} + edit_profile_form.changed_data = ["first_name"] + edit_profile_form.cleaned_data = {'first_name': "Jane"} + make("user.profile.edit", user=user, session=session1, form=edit_profile_form) + make("user.service.sso.authorization", user=user, session=session1, service=service) + make("user.service.sso", user=user, session=session1, service=service, how="password") + make("user.service.sso.unauthorization", user=user, session=session1, service=service) + make("user.deletion", user=user, session=session1, service=service) + + make("user.password.reset.request", email="USER@example.com", user=user) + make("user.password.reset.failure", email="USER@example.com") + make("user.password.reset", user=user) + + make("user.login", user=agent, session=session2, how="saml") + + create_form = mock.Mock(spec=["instance"]) + create_form.instance = user + make("manager.user.creation", user=agent, session=session2, form=create_form) + + edit_form = mock.Mock(spec=["instance", "initial", "changed_data", "cleaned_data"]) + edit_form.instance = user + edit_form.initial = {'email': "user@example.com", 'first_name': "John"} + edit_form.changed_data = ["first_name"] + edit_form.cleaned_data = {'first_name': "Jane"} + make("manager.user.profile.edit", user=agent, session=session2, form=edit_form) + + change_email_form = mock.Mock(spec=["instance", "cleaned_data"]) + change_email_form.instance = user + change_email_form.cleaned_data = {'new_email': "jane@example.com"} + make( + "manager.user.email.change.request", user=agent, session=session2, form=change_email_form, + ) + + password_change_form = mock.Mock(spec=["instance", "cleaned_data"]) + password_change_form.instance = user + password_change_form.cleaned_data = {'generate_password': False, 'send_mail': False} + make( + "manager.user.password.change", user=agent, session=session2, form=password_change_form, + ) + + password_change_form.cleaned_data["send_mail"] = True + make( + "manager.user.password.change", user=agent, session=session2, form=password_change_form, + ) + + make( + "manager.user.password.reset.request", user=agent, session=session2, target_user=user, + ) + + make( + "manager.user.password.change.force", user=agent, session=session2, target_user=user, + ) + make( + "manager.user.password.change.unforce", user=agent, session=session2, target_user=user, + ) + + make("manager.user.activation", user=agent, session=session2, target_user=user) + make("manager.user.deactivation", user=agent, session=session2, target_user=user) + make("manager.user.deletion", user=agent, session=session2, target_user=user) + make( + "manager.user.sso.authorization.deletion", + user=agent, + session=session2, + service=service, + target_user=user, + ) + + make("manager.role.creation", user=agent, session=session2, role=role_user) + role_edit_form = mock.Mock(spec=["instance", "initial", "changed_data", "cleaned_data"]) + role_edit_form.instance = role_user + role_edit_form.initial = {'name': role_user.name} + role_edit_form.changed_data = ["name"] + role_edit_form.cleaned_data = {'name': "changed role name"} + make( + "manager.role.edit", user=agent, session=session2, role=role_user, form=role_edit_form, + ) + make("manager.role.deletion", user=agent, session=session2, role=role_user) + make( + "manager.role.membership.grant", user=agent, session=session2, role=role_user, member=user, + ) + make( + "manager.role.membership.removal", user=agent, session=session2, role=role_user, member=user, + ) + + make( + "manager.role.inheritance.addition", user=agent, session=session2, parent=role_agent, child=role_user, + ) + make( + "manager.role.inheritance.removal", user=agent, session=session2, parent=role_agent, child=role_user, + ) + + make( + "manager.role.administrator.role.addition", + user=agent, + session=session2, + role=role_user, + admin_role=role_agent, + ) + make( + "manager.role.administrator.role.removal", + user=agent, + session=session2, + role=role_user, + admin_role=role_agent, + ) + + make( + "manager.role.administrator.user.addition", + user=agent, + session=session2, + role=role_user, + admin_user=user, + ) + make( + "manager.role.administrator.user.removal", + user=agent, + session=session2, + role=role_user, + admin_user=user, + ) + + # verify we created at least one event for each type + assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry) + + return locals() + + +def extract_journal(response): + rows = [] + seen_event_ids = set() + while True: + for tr in response.pyquery("tr[data-event-type]"): + # page can overlap when they contain less than 20 items (to prevent orphan rows) + event_id = tr.attrib["data-event-id"] + if event_id not in seen_event_ids: + rows.append(response.pyquery(tr)) + seen_event_ids.add(event_id) + if "Previous page" not in response: + break + response = response.click("Previous page", index=0) + + rows.reverse() + content = [ + { + 'timestamp': text_content(row.find(".journal-list--timestamp-column")[0]).strip(), + 'type': row[0].attrib["data-event-type"], + 'user': text_content(row.find(".journal-list--user-column")[0]).strip(), + 'message': text_content(row.find(".journal-list--message-column")[0]), + } + for row in rows + ] + return content + + +def test_global_journal(app, superuser, events): + response = login(app, user=superuser, path="/manage/") + + # remove event about admin login + Event.objects.filter(user=superuser).delete() + + response = response.click(href="journal") + + content = extract_journal(response) + + assert content == [ + { + 'message': 'registration request with email "user@example.com"', + 'timestamp': 'Jan. 1, 2020, midnight', + 'type': 'user.registration.request', + 'user': '-', + }, + { + 'message': 'registration using franceconnect', + 'timestamp': 'Jan. 1, 2020, 1 a.m.', + 'type': 'user.registration', + 'user': 'Johnny doe', + }, + { + 'message': 'logout', + 'timestamp': 'Jan. 1, 2020, 2 a.m.', + 'type': 'user.logout', + 'user': 'Johnny doe', + }, + { + 'message': 'login failure with username "user"', + 'timestamp': 'Jan. 1, 2020, 3 a.m.', + 'type': 'user.login.failure', + 'user': '-', + }, + { + 'message': 'login failure with username "agent"', + 'timestamp': 'Jan. 1, 2020, 4 a.m.', + 'type': 'user.login.failure', + 'user': '-', + }, + { + 'message': 'login using password', + 'timestamp': 'Jan. 1, 2020, 5 a.m.', + 'type': 'user.login', + 'user': 'Johnny doe', + }, + { + 'message': 'password change', + 'timestamp': 'Jan. 1, 2020, 6 a.m.', + 'type': 'user.password.change', + 'user': 'Johnny doe', + }, + { + 'message': 'profile edit (first name)', + 'timestamp': 'Jan. 1, 2020, 7 a.m.', + 'type': 'user.profile.edit', + 'user': 'Johnny doe', + }, + { + 'message': 'authorization of single sign on with "service"', + 'timestamp': 'Jan. 1, 2020, 8 a.m.', + 'type': 'user.service.sso.authorization', + 'user': 'Johnny doe', + }, + { + 'message': 'service single sign on with "service"', + 'timestamp': 'Jan. 1, 2020, 9 a.m.', + 'type': 'user.service.sso', + 'user': 'Johnny doe', + }, + { + 'message': 'unauthorization of single sign on with "service"', + 'timestamp': 'Jan. 1, 2020, 10 a.m.', + 'type': 'user.service.sso.unauthorization', + 'user': 'Johnny doe', + }, + { + 'message': 'deletion', + 'timestamp': 'Jan. 1, 2020, 11 a.m.', + 'type': 'user.deletion', + 'user': 'Johnny doe', + }, + { + 'message': 'password reset request with email "user@example.com"', + 'timestamp': 'Jan. 1, 2020, noon', + 'type': 'user.password.reset.request', + 'user': 'Johnny doe', + }, + { + 'message': 'password reset failure with email "USER@example.com"', + 'timestamp': 'Jan. 1, 2020, 1 p.m.', + 'type': 'user.password.reset.failure', + 'user': '-', + }, + { + 'message': 'password reset', + 'timestamp': 'Jan. 1, 2020, 2 p.m.', + 'type': 'user.password.reset', + 'user': 'Johnny doe', + }, + { + 'message': 'login using SAML', + 'timestamp': 'Jan. 1, 2020, 3 p.m.', + 'type': 'user.login', + 'user': 'agent', + }, + { + 'message': 'creation of user "Johnny doe"', + 'timestamp': 'Jan. 1, 2020, 4 p.m.', + 'type': 'manager.user.creation', + 'user': 'agent', + }, + { + 'message': 'edit of user "Johnny doe" (first name)', + 'timestamp': 'Jan. 1, 2020, 5 p.m.', + 'type': 'manager.user.profile.edit', + 'user': 'agent', + }, + { + 'message': 'email change of user "Johnny doe" for email address "jane@example.com"', + 'timestamp': 'Jan. 1, 2020, 6 p.m.', + 'type': 'manager.user.email.change.request', + 'user': 'agent', + }, + { + 'message': 'password change of user "Johnny doe"', + 'timestamp': 'Jan. 1, 2020, 7 p.m.', + 'type': 'manager.user.password.change', + 'user': 'agent', + }, + { + 'message': 'password change of user "Johnny doe" and notification by mail', + 'timestamp': 'Jan. 1, 2020, 8 p.m.', + 'type': 'manager.user.password.change', + 'user': 'agent', + }, + { + 'message': 'password reset request of "Johnny doe" sent to "user@example.com"', + 'timestamp': 'Jan. 1, 2020, 9 p.m.', + 'type': 'manager.user.password.reset.request', + 'user': 'agent', + }, + { + 'message': 'mandatory password change at next login set for user "Johnny doe"', + 'timestamp': 'Jan. 1, 2020, 10 p.m.', + 'type': 'manager.user.password.change.force', + 'user': 'agent', + }, + { + 'message': 'mandatory password change at next login unset for user "Johnny doe"', + 'timestamp': 'Jan. 1, 2020, 11 p.m.', + 'type': 'manager.user.password.change.unforce', + 'user': 'agent', + }, + { + 'message': 'activation of user "Johnny doe"', + 'timestamp': 'Jan. 2, 2020, midnight', + 'type': 'manager.user.activation', + 'user': 'agent', + }, + { + 'message': 'deactivation of user "Johnny doe"', + 'timestamp': 'Jan. 2, 2020, 1 a.m.', + 'type': 'manager.user.deactivation', + 'user': 'agent', + }, + { + 'message': 'deletion of user "Johnny doe"', + 'timestamp': 'Jan. 2, 2020, 2 a.m.', + 'type': 'manager.user.deletion', + 'user': 'agent', + }, + { + 'message': 'deletion of authorization of single sign on with "service" of ' 'user "Johnny doe"', + 'timestamp': 'Jan. 2, 2020, 3 a.m.', + 'type': 'manager.user.sso.authorization.deletion', + 'user': 'agent', + }, + { + 'message': 'creation of role "role1"', + 'timestamp': 'Jan. 2, 2020, 4 a.m.', + 'type': 'manager.role.creation', + 'user': 'agent', + }, + { + 'message': 'edit of role "role1" (name)', + 'timestamp': 'Jan. 2, 2020, 5 a.m.', + 'type': 'manager.role.edit', + 'user': 'agent', + }, + { + 'message': 'deletion of role "role1"', + 'timestamp': 'Jan. 2, 2020, 6 a.m.', + 'type': 'manager.role.deletion', + 'user': 'agent', + }, + { + 'message': 'membership grant to user "user (111111)" in role "role1"', + 'timestamp': 'Jan. 2, 2020, 7 a.m.', + 'type': 'manager.role.membership.grant', + 'user': 'agent', + }, + { + 'message': 'membership removal of user "user (111111)" from role "role1"', + 'timestamp': 'Jan. 2, 2020, 8 a.m.', + 'type': 'manager.role.membership.removal', + 'user': 'agent', + }, + { + 'message': 'inheritance addition from parent role "role2" to child role ' '"role1"', + 'timestamp': 'Jan. 2, 2020, 9 a.m.', + 'type': 'manager.role.inheritance.addition', + 'user': 'agent', + }, + { + 'message': 'inheritance removal from parent role "role2" to child role ' '"role1"', + 'timestamp': 'Jan. 2, 2020, 10 a.m.', + 'type': 'manager.role.inheritance.removal', + 'user': 'agent', + }, + { + 'message': 'addition of role "role2" as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, 11 a.m.', + 'type': 'manager.role.administrator.role.addition', + 'user': 'agent', + }, + { + 'message': 'removal of role "role2" as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, noon', + 'type': 'manager.role.administrator.role.removal', + 'user': 'agent', + }, + { + 'message': 'addition of user "user (111111)" as administrator of role ' '"role1"', + 'timestamp': 'Jan. 2, 2020, 1 p.m.', + 'type': 'manager.role.administrator.user.addition', + 'user': 'agent', + }, + { + 'message': 'removal of user "user (111111)" as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, 2 p.m.', + 'type': 'manager.role.administrator.user.removal', + 'user': 'agent', + }, + ] + + +def test_user_journal(app, superuser, events): + response = login(app, user=superuser, path="/manage/") + user = User.objects.get(username="user") + + response = app.get("/manage/users/%s/journal/" % user.id) + content = extract_journal(response) + + assert content == [ + { + 'message': 'registration using franceconnect', + 'timestamp': 'Jan. 1, 2020, 1 a.m.', + 'type': 'user.registration', + 'user': 'Johnny doe', + }, + { + 'message': 'logout', + 'timestamp': 'Jan. 1, 2020, 2 a.m.', + 'type': 'user.logout', + 'user': 'Johnny doe', + }, + { + 'message': 'login using password', + 'timestamp': 'Jan. 1, 2020, 5 a.m.', + 'type': 'user.login', + 'user': 'Johnny doe', + }, + { + 'message': 'password change', + 'timestamp': 'Jan. 1, 2020, 6 a.m.', + 'type': 'user.password.change', + 'user': 'Johnny doe', + }, + { + 'message': 'profile edit (first name)', + 'timestamp': 'Jan. 1, 2020, 7 a.m.', + 'type': 'user.profile.edit', + 'user': 'Johnny doe', + }, + { + 'message': 'authorization of single sign on with "service"', + 'timestamp': 'Jan. 1, 2020, 8 a.m.', + 'type': 'user.service.sso.authorization', + 'user': 'Johnny doe', + }, + { + 'message': 'service single sign on with "service"', + 'timestamp': 'Jan. 1, 2020, 9 a.m.', + 'type': 'user.service.sso', + 'user': 'Johnny doe', + }, + { + 'message': 'unauthorization of single sign on with "service"', + 'timestamp': 'Jan. 1, 2020, 10 a.m.', + 'type': 'user.service.sso.unauthorization', + 'user': 'Johnny doe', + }, + { + 'message': 'deletion', + 'timestamp': 'Jan. 1, 2020, 11 a.m.', + 'type': 'user.deletion', + 'user': 'Johnny doe', + }, + { + 'message': 'password reset request with email "user@example.com"', + 'timestamp': 'Jan. 1, 2020, noon', + 'type': 'user.password.reset.request', + 'user': 'Johnny doe', + }, + { + 'message': 'password reset', + 'timestamp': 'Jan. 1, 2020, 2 p.m.', + 'type': 'user.password.reset', + 'user': 'Johnny doe', + }, + { + 'message': 'creation by administrator', + 'timestamp': 'Jan. 1, 2020, 4 p.m.', + 'type': 'manager.user.creation', + 'user': 'agent', + }, + { + 'message': 'edit by administrator (first name)', + 'timestamp': 'Jan. 1, 2020, 5 p.m.', + 'type': 'manager.user.profile.edit', + 'user': 'agent', + }, + { + 'message': 'email change for email address "jane@example.com" requested by administrator', + 'timestamp': 'Jan. 1, 2020, 6 p.m.', + 'type': 'manager.user.email.change.request', + 'user': 'agent', + }, + { + 'message': 'password change by administrator', + 'timestamp': 'Jan. 1, 2020, 7 p.m.', + 'type': 'manager.user.password.change', + 'user': 'agent', + }, + { + 'message': 'password change by administrator and notification by mail', + 'timestamp': 'Jan. 1, 2020, 8 p.m.', + 'type': 'manager.user.password.change', + 'user': 'agent', + }, + { + 'message': "password reset request by administrator sent to " '"user@example.com"', + 'timestamp': 'Jan. 1, 2020, 9 p.m.', + 'type': 'manager.user.password.reset.request', + 'user': 'agent', + }, + { + 'message': 'mandatory password change at next login set by administrator', + 'timestamp': 'Jan. 1, 2020, 10 p.m.', + 'type': 'manager.user.password.change.force', + 'user': 'agent', + }, + { + 'message': 'mandatory password change at next login unset by administrator', + 'timestamp': 'Jan. 1, 2020, 11 p.m.', + 'type': 'manager.user.password.change.unforce', + 'user': 'agent', + }, + { + 'message': 'activation by administrator', + 'timestamp': 'Jan. 2, 2020, midnight', + 'type': 'manager.user.activation', + 'user': 'agent', + }, + { + 'message': 'deactivation by administrator', + 'timestamp': 'Jan. 2, 2020, 1 a.m.', + 'type': 'manager.user.deactivation', + 'user': 'agent', + }, + { + 'message': 'deletion by administrator', + 'timestamp': 'Jan. 2, 2020, 2 a.m.', + 'type': 'manager.user.deletion', + 'user': 'agent', + }, + { + 'message': 'deletion of authorization of single sign on with "service" by ' "administrator", + 'timestamp': 'Jan. 2, 2020, 3 a.m.', + 'type': 'manager.user.sso.authorization.deletion', + 'user': 'agent', + }, + { + 'message': 'membership grant in role "role1"', + 'timestamp': 'Jan. 2, 2020, 7 a.m.', + 'type': 'manager.role.membership.grant', + 'user': 'agent', + }, + { + 'message': 'membership removal from role "role1"', + 'timestamp': 'Jan. 2, 2020, 8 a.m.', + 'type': 'manager.role.membership.removal', + 'user': 'agent', + }, + { + 'message': 'addition as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, 1 p.m.', + 'type': 'manager.role.administrator.user.addition', + 'user': 'agent', + }, + { + 'message': 'removal as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, 2 p.m.', + 'type': 'manager.role.administrator.user.removal', + 'user': 'agent', + }, + ] + + +def test_role_journal(app, superuser, events): + response = login(app, user=superuser, path="/manage/") + role1 = Role.objects.get(name="role1") + role2 = Role.objects.get(name="role2") + + response = app.get("/manage/roles/%s/journal/" % role1.id) + content = extract_journal(response) + + assert content == [ + { + 'message': 'creation', + 'timestamp': 'Jan. 2, 2020, 4 a.m.', + 'type': 'manager.role.creation', + 'user': 'agent', + }, + { + 'message': 'edit (name)', + 'timestamp': 'Jan. 2, 2020, 5 a.m.', + 'type': 'manager.role.edit', + 'user': 'agent', + }, + { + 'message': 'deletion', + 'timestamp': 'Jan. 2, 2020, 6 a.m.', + 'type': 'manager.role.deletion', + 'user': 'agent', + }, + { + 'message': 'membership grant to user "user (111111)"', + 'timestamp': 'Jan. 2, 2020, 7 a.m.', + 'type': 'manager.role.membership.grant', + 'user': 'agent', + }, + { + 'message': 'membership removal of user "user (111111)"', + 'timestamp': 'Jan. 2, 2020, 8 a.m.', + 'type': 'manager.role.membership.removal', + 'user': 'agent', + }, + { + 'message': 'inheritance addition from parent role "role2"', + 'timestamp': 'Jan. 2, 2020, 9 a.m.', + 'type': 'manager.role.inheritance.addition', + 'user': 'agent', + }, + { + 'message': 'inheritance removal from parent role "role2"', + 'timestamp': 'Jan. 2, 2020, 10 a.m.', + 'type': 'manager.role.inheritance.removal', + 'user': 'agent', + }, + { + 'message': 'addition of role "role2" as administrator', + 'timestamp': 'Jan. 2, 2020, 11 a.m.', + 'type': 'manager.role.administrator.role.addition', + 'user': 'agent', + }, + { + 'message': 'removal of role "role2" as administrator', + 'timestamp': 'Jan. 2, 2020, noon', + 'type': 'manager.role.administrator.role.removal', + 'user': 'agent', + }, + { + 'message': 'addition of user "user (111111)" as administrator', + 'timestamp': 'Jan. 2, 2020, 1 p.m.', + 'type': 'manager.role.administrator.user.addition', + 'user': 'agent', + }, + { + 'message': 'removal of user "user (111111)" as administrator', + 'timestamp': 'Jan. 2, 2020, 2 p.m.', + 'type': 'manager.role.administrator.user.removal', + 'user': 'agent', + }, + ] + + response = app.get("/manage/roles/%s/journal/" % role2.id) + content = extract_journal(response) + + assert content == [ + { + 'message': 'inheritance addition to child role "role1"', + 'timestamp': 'Jan. 2, 2020, 9 a.m.', + 'type': 'manager.role.inheritance.addition', + 'user': 'agent', + }, + { + 'message': 'inheritance removal to child role "role1"', + 'timestamp': 'Jan. 2, 2020, 10 a.m.', + 'type': 'manager.role.inheritance.removal', + 'user': 'agent', + }, + { + 'message': 'addition as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, 11 a.m.', + 'type': 'manager.role.administrator.role.addition', + 'user': 'agent', + }, + { + 'message': 'removal as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, noon', + 'type': 'manager.role.administrator.role.removal', + 'user': 'agent', + }, + ] + + +def test_roles_journal(app, superuser, events): + response = login(app, user=superuser, path='/manage/') + response = response.click('Role') + response = response.click('Journal') + + content = extract_journal(response) + + assert content == [ + { + 'message': 'creation of role "role1"', + 'timestamp': 'Jan. 2, 2020, 4 a.m.', + 'type': 'manager.role.creation', + 'user': 'agent', + }, + { + 'message': 'edit of role "role1" (name)', + 'timestamp': 'Jan. 2, 2020, 5 a.m.', + 'type': 'manager.role.edit', + 'user': 'agent', + }, + { + 'message': 'deletion of role "role1"', + 'timestamp': 'Jan. 2, 2020, 6 a.m.', + 'type': 'manager.role.deletion', + 'user': 'agent', + }, + { + 'message': 'membership grant to user "user (111111)" in role "role1"', + 'timestamp': 'Jan. 2, 2020, 7 a.m.', + 'type': 'manager.role.membership.grant', + 'user': 'agent', + }, + { + 'message': 'membership removal of user "user (111111)" from role "role1"', + 'timestamp': 'Jan. 2, 2020, 8 a.m.', + 'type': 'manager.role.membership.removal', + 'user': 'agent', + }, + { + 'message': 'inheritance addition from parent role "role2" to child role ' '"role1"', + 'timestamp': 'Jan. 2, 2020, 9 a.m.', + 'type': 'manager.role.inheritance.addition', + 'user': 'agent', + }, + { + 'message': 'inheritance removal from parent role "role2" to child role ' '"role1"', + 'timestamp': 'Jan. 2, 2020, 10 a.m.', + 'type': 'manager.role.inheritance.removal', + 'user': 'agent', + }, + { + 'message': 'addition of role "role2" as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, 11 a.m.', + 'type': 'manager.role.administrator.role.addition', + 'user': 'agent', + }, + { + 'message': 'removal of role "role2" as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, noon', + 'type': 'manager.role.administrator.role.removal', + 'user': 'agent', + }, + { + 'message': 'addition of user "user (111111)" as administrator of role ' '"role1"', + 'timestamp': 'Jan. 2, 2020, 1 p.m.', + 'type': 'manager.role.administrator.user.addition', + 'user': 'agent', + }, + { + 'message': 'removal of user "user (111111)" as administrator of role "role1"', + 'timestamp': 'Jan. 2, 2020, 2 p.m.', + 'type': 'manager.role.administrator.user.removal', + 'user': 'agent', + }, + ] + + +def test_date_navigation(app, superuser, events): + response = login(app, user=superuser, path="/manage/journal/") + response = response.click('2020') + response = response.click('January') + response = response.click('1') + response = response.click('January 2020') + response = response.click('2020') + response = response.click('All dates') + + +def test_search(app, superuser, events): + response = login(app, user=superuser, path="/manage/journal/") + response.form.set('search', 'event:registration') + response = response.form.submit() + assert len(response.pyquery('tbody tr')) == 1 + + response.form.set('search', 'username:agent event:login') + response = response.form.submit() + assert len(response.pyquery('tbody tr')) == 1 + assert all( + 'agent' == text_content(node) for node in response.pyquery('tbody tr td.journal-list--user-column') + ) + + response.form.set('search', 'uuid:%s event:reset' % events['user'].uuid) + response = response.form.submit() + assert len(response.pyquery('tbody tr')) == 1 + + response.form.set('search', 'session:1234') + response = response.form.submit() + assert len(response.pyquery('tbody tr')) == 9 + assert all( + text_content(node) == 'Johnny doe' + for node in response.pyquery('tbody tr td.journal-list--user-column') + ) + + response.form.set('search', 'email:jane@example.com') + response = response.form.submit() + assert ( + text_content(response.pyquery('tbody tr td.journal-list--message-column')[0]).strip() + == 'email change of user "Johnny doe" for email address "jane@example.com"' + ) + + response.form.set('search', 'jane@example.com') + response = response.form.submit() + assert ( + text_content(response.pyquery('tbody tr td.journal-list--message-column')[0]).strip() + == 'email change of user "Johnny doe" for email address "jane@example.com"' + ) + + response.form.set('search', 'johny doe event:login') + response = response.form.submit() + pq = response.pyquery + + assert [ + list(map(text_content, p)) + for p in zip(pq('tbody td.journal-list--user-column'), pq('tbody td.journal-list--message-column')) + ] == [['Johnny doe', 'login using password']]