summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenjamin Dauvergne <bdauvergne@entrouvert.com>2020-10-13 17:08:43 (GMT)
committerBenjamin Dauvergne <bdauvergne@entrouvert.com>2020-10-15 13:22:31 (GMT)
commit8487d33cff4c18211056f56dbdc58b67daa27691 (patch)
treeceaa5a9cec10b5d5df52b6a2159d0110bb1110e1
parent1cc04e3ad7ad62b07861e9f992b84a86ef88a839 (diff)
downloadauthentic-8487d33cff4c18211056f56dbdc58b67daa27691.zip
authentic-8487d33cff4c18211056f56dbdc58b67daa27691.tar.gz
authentic-8487d33cff4c18211056f56dbdc58b67daa27691.tar.bz2
misc: integration of journal in manager (#47155)
-rw-r--r--src/authentic2/custom_user/managers.py8
-rw-r--r--src/authentic2/manager/forms.py2
-rw-r--r--src/authentic2/manager/journal_event_types.py503
-rw-r--r--src/authentic2/manager/journal_views.py98
-rw-r--r--src/authentic2/manager/role_views.py70
-rw-r--r--src/authentic2/manager/static/authentic2/manager/js/manager.js15
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/journal.html43
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/role_common.html4
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/role_journal.html10
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/role_members.html7
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/roles.html3
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/roles_journal.html9
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/user_detail.html1
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/user_journal.html10
-rw-r--r--src/authentic2/manager/urls.py22
-rw-r--r--src/authentic2/manager/user_views.py55
-rw-r--r--src/authentic2/manager/views.py39
-rw-r--r--tests/test_manager_journal.py892
18 files changed, 1759 insertions, 32 deletions
diff --git a/src/authentic2/custom_user/managers.py b/src/authentic2/custom_user/managers.py
index 46aab0b..066846e 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 b871773..5dbdcd8 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 0000000..82459e9
--- /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 <http://www.gnu.org/licenses/>.
+
+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 0000000..e764db2
--- /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 <http://www.gnu.org/licenses/>.
+
+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 <tt>uuid:1234</tt> to find all events related \
+to user whose UUID is <tt>1234</tt>.'''
+ )
+ 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 22ba336..bba2dbc 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 0afdf65..7a879fd 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 0000000..8d49d0e
--- /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 %}
+ <a href="#">{% trans 'Journal' %}</a>
+ {% for caption, url in date_hierarchy.back_urls %}
+ <a href="{{ url }}">{{ caption }}</a>
+ {% endfor %}
+{% endblock %}
+
+{% block sidebar %}
+ <aside id="sidebar">
+ <h3>{% trans "Search" context "title" %}</h3>
+ <div class="journal-list--search-form">
+ <form action="{{ form.url }}">
+ {{ form|with_template }}
+ <button>{% trans "Search" %}</button>
+ </form>
+ </div>
+ {% if date_hierarchy.choice_name %}
+ <h4>{{ date_hierarchy.choice_name }}</h4>
+ <p>
+ {% for caption, url in date_hierarchy.choice_urls %}
+ <a href="{{ url }}">{{ caption }}</a>
+ {% endfor %}
+ </p>
+ {% endif %}
+ <div class="documentation">
+ {% for documentation in form.search_engine_class.documentation %}
+ <p>{{ documentation|safe }}</p>
+ {% endfor %}
+ </div>
+ </aside>
+{% 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 245a695..24e2b3a 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 }}
<a href="{% url 'a2-manager-roles' %}">{% trans 'Roles' %}</a>
+ {% firstof ou object.ou as current_ou %}
+ {% if multiple_ou and current_ou %}
+ <a href="{% url 'a2-manager-roles' %}?search-ou={{ current_ou.id }}">{{ current_ou }}</a>
+ {% 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 0000000..d3fbb4c
--- /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 %}
+ <a href="{% url 'a2-manager-roles' %}">{% trans 'Roles' %}</a>
+ {% if multiple_ou and object.ou %}
+ <a href="../?search-ou={{ object.ou.pk }}">{{ object.ou }}</a>
+ {% endif %}
+ <a href="../">{{ object }}</a>
+{% endblock %}
diff --git a/src/authentic2/manager/templates/authentic2/manager/role_members.html b/src/authentic2/manager/templates/authentic2/manager/role_members.html
index 37177cd..03ef962 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 %}
- <a href="../?search-ou={{ object.ou.pk }}">{{ object.ou }}</a>
- {% endif %}
<a href="#">{{ object }}</a>
{% endblock %}
@@ -19,6 +16,7 @@
{% block appbar %}
{{ block.super }}
<span class="actions">
+ <a class="extra-actions-menu-opener"></a>
{% if not object.is_internal and view.can_delete %}
<a rel="popup" href="{% url "a2-manager-role-delete" pk=object.pk %}">{% trans "Delete" %}</a>
{% else %}
@@ -36,6 +34,9 @@
{% if perms.a2_rbac.admin_permission %}
<a href="{% url "a2-manager-role-permissions" pk=object.pk %}">{% trans "Permissions" %}</a>
{% endif %}
+ <ul class="extra-actions-menu">
+ <li><a href="{% url "a2-manager-role-journal" pk=object.pk %}">{% trans "Journal" %}</a></li>
+ </ul>
</span>
{% endblock %}
diff --git a/src/authentic2/manager/templates/authentic2/manager/roles.html b/src/authentic2/manager/templates/authentic2/manager/roles.html
index 3aa5398..9cc1c9a 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 }}
<span class="actions">
@@ -13,6 +15,7 @@
<a href="#" class="disabled" rel="popup">{% trans "Add role" %}</a>
{% endif %}
<ul class="extra-actions-menu">
+ <li><a href="{% url "a2-manager-roles-journal" %}{% if multiple_ou and ou %}?search-ou={{ ou.id }}{% endif %}">{% trans "Journal" %}</a></li>
<li><a download href="{% url 'a2-manager-roles-export' format="json" %}?{{ request.GET.urlencode }}">{% trans 'Export' %}</a></li>
{% if view.can_add %}
<li><a href="{% url 'a2-manager-roles-import' %}?{{ request.GET.urlencode }}" rel="popup">{% trans 'Import' %}</a></li>
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 0000000..f8a56fa
--- /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 %}
+ <a href="{% url 'a2-manager-roles' %}">{% trans 'Roles' %}</a>
+ {% if multiple_ou and ou %}
+ <a href="../?search-ou={{ ou.id }}">{{ ou }}</a>
+ {% 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 b3b7a03..1de7713 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 %}
<li><a href="{% url "a2-manager-user-authorizations" pk=object.pk %}">{% trans "Consents" %}</a></li>
{% endif %}
+ <li><a href="{% url "a2-manager-user-journal" pk=object.pk %}">{% trans "Journal" %}</a></li>
</ul>
</span>
{% 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 0000000..894d3a4
--- /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 %}
+ <a href="{% url 'a2-manager-users' %}">{% trans 'Users' %}</a>
+ {% if multiple_ou and object.ou %}
+ <a href="../?search-ou={{ object.ou.pk }}">{{ object.ou }}</a>
+ {% endif %}
+ <a href="{% url 'a2-manager-user-detail' pk=object.pk %}">{{ object.get_full_name }}</a>
+{% endblock %}
diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py
index 5dbd5b6..f6d4eb3 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<pk>\d+)/su/$', user_views.su,
name='a2-manager-user-su'),
+ url(r'^users/(?P<pk>\d+)/authorizations/$',
+ user_views.user_authorizations,
+ name='a2-manager-user-authorizations'),
+ url(r'^users/(?P<pk>\d+)/journal/$',
+ user_views.user_journal,
+ name='a2-manager-user-journal'),
# by uuid
url(r'^users/uuid:(?P<slug>[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<slug>[a-z0-9]+)/change-email/$',
user_views.user_change_email,
name='a2-manager-user-by-uuid-change-email'),
- url(r'^users/(?P<pk>\d+)/authorizations/$',
- user_views.user_authorizations,
- name='a2-manager-user-authorizations'),
+ url(r'^users/uuid:(?P<slug>[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/(?P<format>csv|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<pk>\d+)/$', role_views.members,
name='a2-manager-role-members'),
url(r'^roles/(?P<pk>\d+)/add-child/$', role_views.add_child,
@@ -124,6 +132,8 @@ urlpatterns = required(
name='a2-manager-role-edit'),
url(r'^roles/(?P<pk>\d+)/permissions/$', role_views.permissions,
name='a2-manager-role-permissions'),
+ url(r'^roles/(?P<pk>\d+)/journal/$', role_views.journal,
+ name='a2-manager-role-journal'),
# Authentic2 organizational units
@@ -152,6 +162,10 @@ urlpatterns = required(
url(r'^services/(?P<service_pk>\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 78d9fb7..a6826d9 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 33aa387..31c953b 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 0000000..6c2ea0a
--- /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 <http://www.gnu.org/licenses/>.
+
+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']]