From 6cc2835d31f4175388fd6095986220206b12dd55 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Tue, 9 Jul 2024 18:14:05 +0530 Subject: [PATCH 01/74] [chore] REST API changes - Updated the `NotificationSettingSerializer` to also include `organization_name`. - New endpoint `/api/user-setting/organization//` to allow changes toggling of email/web notification settings of a particular org with just a single API call. --- openwisp_notifications/api/serializers.py | 11 ++++++++++ openwisp_notifications/api/urls.py | 5 +++++ openwisp_notifications/api/views.py | 26 ++++++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/openwisp_notifications/api/serializers.py b/openwisp_notifications/api/serializers.py index d4dbd41d..e67fcb73 100644 --- a/openwisp_notifications/api/serializers.py +++ b/openwisp_notifications/api/serializers.py @@ -73,6 +73,11 @@ class Meta(NotificationSerializer.Meta): class NotificationSettingSerializer(serializers.ModelSerializer): + organization_name = serializers.SerializerMethodField() + + def get_organization_name(self, obj): + return obj.organization.name if obj.organization else None + class Meta: model = NotificationSetting exclude = ['user'] @@ -87,3 +92,9 @@ class Meta: 'object_content_type', 'object_id', ] + + +class NotificationSettingUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = NotificationSetting + fields = ['email', 'web'] diff --git a/openwisp_notifications/api/urls.py b/openwisp_notifications/api/urls.py index 597d2a74..4b2b3b16 100644 --- a/openwisp_notifications/api/urls.py +++ b/openwisp_notifications/api/urls.py @@ -27,6 +27,11 @@ def get_api_urls(api_views=None): views.notification_setting, name='notification_setting', ), + path( + 'user-setting/organization//', + views.org_notification_setting, + name='org_notification_setting', + ), path( 'ignore/', views.ignore_object_notification_list, diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index 7320c72e..3a9fa8d1 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -14,14 +14,16 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView from openwisp_notifications.api.serializers import ( IgnoreObjectNotificationSerializer, NotificationListSerializer, NotificationSerializer, NotificationSettingSerializer, + NotificationSettingUpdateSerializer, ) -from openwisp_notifications.swapper import load_model +from openwisp_notifications.swapper import load_model, swapper_load_model from openwisp_users.api.authentication import BearerAuthentication from .filters import NotificationSettingFilter @@ -135,6 +137,27 @@ class NotificationSettingView(BaseNotificationSettingView, RetrieveUpdateAPIView lookup_field = 'pk' +class OrganizationNotificationSettingView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, organization_id): + notification_settings = NotificationSetting.objects.filter( + organization_id=organization_id, user=request.user + ) + + serializer = NotificationSettingUpdateSerializer(data=request.data) + if serializer.is_valid(): + email = serializer.validated_data.get('email') + web = serializer.validated_data.get('web') + + # Update all notification settings for the specified organization + notification_settings.update(email=email, web=web) + + return Response(status=status.HTTP_200_OK) + else: + return Response(status=status.HTTP_400_BAD_REQUEST) + + class BaseIgnoreObjectNotificationView(GenericAPIView): model = IgnoreObjectNotification serializer_class = IgnoreObjectNotificationSerializer @@ -204,5 +227,6 @@ def perform_create(self, serializer): notification_read_redirect = NotificationReadRedirect.as_view() notification_setting_list = NotificationSettingListView.as_view() notification_setting = NotificationSettingView.as_view() +org_notification_setting = OrganizationNotificationSettingView.as_view() ignore_object_notification_list = IgnoreObjectNotificationListView.as_view() ignore_object_notification = IgnoreObjectNotificationView.as_view() From 7016f863eca7cf488c158c565f8770e62635d0b6 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sun, 14 Jul 2024 23:29:47 +0530 Subject: [PATCH 02/74] [chore] Add tests --- openwisp_notifications/api/urls.py | 15 +++-- openwisp_notifications/api/views.py | 70 ++++++++++++++++-------- openwisp_notifications/tests/test_api.py | 35 ++++++++++++ 3 files changed, 91 insertions(+), 29 deletions(-) diff --git a/openwisp_notifications/api/urls.py b/openwisp_notifications/api/urls.py index 4b2b3b16..5cb004c7 100644 --- a/openwisp_notifications/api/urls.py +++ b/openwisp_notifications/api/urls.py @@ -27,11 +27,6 @@ def get_api_urls(api_views=None): views.notification_setting, name='notification_setting', ), - path( - 'user-setting/organization//', - views.org_notification_setting, - name='org_notification_setting', - ), path( 'ignore/', views.ignore_object_notification_list, @@ -42,4 +37,14 @@ def get_api_urls(api_views=None): views.ignore_object_notification, name='ignore_object_notification', ), + path( + 'user-setting/organization//', + views.organization_notification_setting, + name='organization_notification_setting', + ), + path( + 'user//setting/organization//', + views.admin_user_organization_notification_setting, + name='admin_user_organization_notification_setting', + ), ] diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index 3a9fa8d1..ff73ed43 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -12,7 +12,7 @@ ) from rest_framework.mixins import CreateModelMixin, ListModelMixin, UpdateModelMixin from rest_framework.pagination import PageNumberPagination -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -23,7 +23,7 @@ NotificationSettingSerializer, NotificationSettingUpdateSerializer, ) -from openwisp_notifications.swapper import load_model, swapper_load_model +from openwisp_notifications.swapper import load_model from openwisp_users.api.authentication import BearerAuthentication from .filters import NotificationSettingFilter @@ -137,27 +137,6 @@ class NotificationSettingView(BaseNotificationSettingView, RetrieveUpdateAPIView lookup_field = 'pk' -class OrganizationNotificationSettingView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request, organization_id): - notification_settings = NotificationSetting.objects.filter( - organization_id=organization_id, user=request.user - ) - - serializer = NotificationSettingUpdateSerializer(data=request.data) - if serializer.is_valid(): - email = serializer.validated_data.get('email') - web = serializer.validated_data.get('web') - - # Update all notification settings for the specified organization - notification_settings.update(email=email, web=web) - - return Response(status=status.HTTP_200_OK) - else: - return Response(status=status.HTTP_400_BAD_REQUEST) - - class BaseIgnoreObjectNotificationView(GenericAPIView): model = IgnoreObjectNotification serializer_class = IgnoreObjectNotificationSerializer @@ -221,12 +200,55 @@ def perform_create(self, serializer): ) +class OrganizationNotificationSettingView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, organization_id): + notification_settings = NotificationSetting.objects.filter( + organization_id=organization_id, user=request.user + ) + serializer = NotificationSettingUpdateSerializer(data=request.data) + if serializer.is_valid(): + email = serializer.validated_data.get('email') + web = serializer.validated_data.get('web') + + # Update all notification settings for the specific organization + notification_settings.update(email=email, web=web) + + return Response(status=status.HTTP_200_OK) + else: + return Response(status=status.HTTP_400_BAD_REQUEST) + + +class AdminUserOrganizationNotificationSettingView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request, user_id, organization_id): + notification_settings = NotificationSetting.objects.filter( + organization_id=organization_id, user_id=user_id + ) + serializer = NotificationSettingUpdateSerializer(data=request.data) + if serializer.is_valid(): + email = serializer.validated_data.get('email') + web = serializer.validated_data.get('web') + + # Update all notification settings for the specific organization and user + notification_settings.update(email=email, web=web) + + return Response(status=status.HTTP_200_OK) + else: + return Response(status=status.HTTP_400_BAD_REQUEST) + + notifications_list = NotificationListView.as_view() notification_detail = NotificationDetailView.as_view() notifications_read_all = NotificationReadAllView.as_view() notification_read_redirect = NotificationReadRedirect.as_view() notification_setting_list = NotificationSettingListView.as_view() notification_setting = NotificationSettingView.as_view() -org_notification_setting = OrganizationNotificationSettingView.as_view() +organization_notification_setting = OrganizationNotificationSettingView.as_view() +admin_user_organization_notification_setting = ( + AdminUserOrganizationNotificationSettingView.as_view() +) ignore_object_notification_list = IgnoreObjectNotificationListView.as_view() ignore_object_notification = IgnoreObjectNotificationView.as_view() diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index 56f53abc..e793552a 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -671,6 +671,41 @@ def _unread_notification(notification): '{view}?next={url}'.format(view=reverse('admin:login'), url=url), ) + def test_organization_notification_setting_update(self): + org = Organization.objects.first() + url = self._get_path('organization_notification_setting', org.pk) + response = self.client.post(url, data={'web': True, 'email': True}) + self.assertEqual(response.status_code, 200) + notification_setting = NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk + ).first() + self.assertTrue(notification_setting.web, True) + self.assertTrue(notification_setting.email, True) + + def test_admin_user_organization_notification_setting_update(self): + tester = self._create_user() + org = Organization.objects.first() + + with self.subTest('Test for admin user'): + url = self._get_path( + 'admin_user_organization_notification_setting', self.admin.pk, org.pk + ) + response = self.client.post(url, data={'web': True, 'email': True}) + self.assertEqual(response.status_code, 200) + notification_setting = NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk + ).first() + self.assertTrue(notification_setting.web, True) + self.assertTrue(notification_setting.email, True) + + with self.subTest('Test for non-admin user'): + self.client.force_login(tester) + url = self._get_path( + 'admin_user_organization_notification_setting', tester.pk, org.pk + ) + response = self.client.post(url, data={'web': True, 'email': True}) + self.assertEqual(response.status_code, 403) + @patch('openwisp_notifications.tasks.delete_ignore_object_notification.apply_async') def test_create_ignore_obj_notification_api(self, mocked_task): org_user = self._get_org_user() From 6d410fc836a80641a7d98748a64d08db20af9189 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Wed, 17 Jul 2024 19:49:31 +0530 Subject: [PATCH 03/74] [chore] Use GenericAPIView --- openwisp_notifications/api/permissions.py | 17 +++++++++ openwisp_notifications/api/serializers.py | 13 +++++-- openwisp_notifications/api/urls.py | 7 +--- openwisp_notifications/api/views.py | 46 ++++++----------------- openwisp_notifications/tests/test_api.py | 10 ++--- 5 files changed, 42 insertions(+), 51 deletions(-) create mode 100644 openwisp_notifications/api/permissions.py diff --git a/openwisp_notifications/api/permissions.py b/openwisp_notifications/api/permissions.py new file mode 100644 index 00000000..92a2dddf --- /dev/null +++ b/openwisp_notifications/api/permissions.py @@ -0,0 +1,17 @@ +from rest_framework.permissions import BasePermission + + +class IsAuthenticatedToUpdateNotificationSetting(BasePermission): + def has_permission(self, request, view): + user = request.user + user_id = view.kwargs.get('user_id') + + # Allow superusers + if user.is_superuser: + return True + + # Allow users to update their own settings + if user.id == user_id: + return True + + return False diff --git a/openwisp_notifications/api/serializers.py b/openwisp_notifications/api/serializers.py index e67fcb73..9baf8eff 100644 --- a/openwisp_notifications/api/serializers.py +++ b/openwisp_notifications/api/serializers.py @@ -94,7 +94,12 @@ class Meta: ] -class NotificationSettingUpdateSerializer(serializers.ModelSerializer): - class Meta: - model = NotificationSetting - fields = ['email', 'web'] +class NotificationSettingUpdateSerializer(serializers.Serializer): + email = serializers.BooleanField() + web = serializers.BooleanField() + + def update(self, instance, validated_data): + instance.email = validated_data.get('email', instance.email) + instance.web = validated_data.get('web', instance.web) + instance.save() + return instance diff --git a/openwisp_notifications/api/urls.py b/openwisp_notifications/api/urls.py index 5cb004c7..5ecf2051 100644 --- a/openwisp_notifications/api/urls.py +++ b/openwisp_notifications/api/urls.py @@ -38,13 +38,8 @@ def get_api_urls(api_views=None): name='ignore_object_notification', ), path( - 'user-setting/organization//', + 'user//organization//setting/', views.organization_notification_setting, name='organization_notification_setting', ), - path( - 'user//setting/organization//', - views.admin_user_organization_notification_setting, - name='admin_user_organization_notification_setting', - ), ] diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index ff73ed43..0fe787d6 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -12,10 +12,12 @@ ) from rest_framework.mixins import CreateModelMixin, ListModelMixin, UpdateModelMixin from rest_framework.pagination import PageNumberPagination -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.views import APIView +from openwisp_notifications.api.permissions import ( + IsAuthenticatedToUpdateNotificationSetting, +) from openwisp_notifications.api.serializers import ( IgnoreObjectNotificationSerializer, NotificationListSerializer, @@ -200,44 +202,21 @@ def perform_create(self, serializer): ) -class OrganizationNotificationSettingView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request, organization_id): - notification_settings = NotificationSetting.objects.filter( - organization_id=organization_id, user=request.user - ) - serializer = NotificationSettingUpdateSerializer(data=request.data) - if serializer.is_valid(): - email = serializer.validated_data.get('email') - web = serializer.validated_data.get('web') - - # Update all notification settings for the specific organization - notification_settings.update(email=email, web=web) - - return Response(status=status.HTTP_200_OK) - else: - return Response(status=status.HTTP_400_BAD_REQUEST) - - -class AdminUserOrganizationNotificationSettingView(APIView): - permission_classes = [IsAdminUser] +class OrganizationNotificationSettingView(GenericAPIView): + permission_classes = [IsAuthenticated, IsAuthenticatedToUpdateNotificationSetting] + serializer_class = NotificationSettingUpdateSerializer def post(self, request, user_id, organization_id): notification_settings = NotificationSetting.objects.filter( organization_id=organization_id, user_id=user_id ) - serializer = NotificationSettingUpdateSerializer(data=request.data) + serializer = self.get_serializer(data=request.data) if serializer.is_valid(): - email = serializer.validated_data.get('email') - web = serializer.validated_data.get('web') - - # Update all notification settings for the specific organization and user - notification_settings.update(email=email, web=web) - + for notification_setting in notification_settings: + serializer.update(notification_setting, serializer.validated_data) return Response(status=status.HTTP_200_OK) else: - return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) notifications_list = NotificationListView.as_view() @@ -247,8 +226,5 @@ def post(self, request, user_id, organization_id): notification_setting_list = NotificationSettingListView.as_view() notification_setting = NotificationSettingView.as_view() organization_notification_setting = OrganizationNotificationSettingView.as_view() -admin_user_organization_notification_setting = ( - AdminUserOrganizationNotificationSettingView.as_view() -) ignore_object_notification_list = IgnoreObjectNotificationListView.as_view() ignore_object_notification = IgnoreObjectNotificationView.as_view() diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index e793552a..150f777a 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -673,7 +673,7 @@ def _unread_notification(notification): def test_organization_notification_setting_update(self): org = Organization.objects.first() - url = self._get_path('organization_notification_setting', org.pk) + url = self._get_path('organization_notification_setting', self.admin.pk, org.pk) response = self.client.post(url, data={'web': True, 'email': True}) self.assertEqual(response.status_code, 200) notification_setting = NotificationSetting.objects.filter( @@ -688,7 +688,7 @@ def test_admin_user_organization_notification_setting_update(self): with self.subTest('Test for admin user'): url = self._get_path( - 'admin_user_organization_notification_setting', self.admin.pk, org.pk + 'organization_notification_setting', self.admin.pk, org.pk ) response = self.client.post(url, data={'web': True, 'email': True}) self.assertEqual(response.status_code, 200) @@ -700,11 +700,9 @@ def test_admin_user_organization_notification_setting_update(self): with self.subTest('Test for non-admin user'): self.client.force_login(tester) - url = self._get_path( - 'admin_user_organization_notification_setting', tester.pk, org.pk - ) + url = self._get_path('organization_notification_setting', tester.pk, org.pk) response = self.client.post(url, data={'web': True, 'email': True}) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) @patch('openwisp_notifications.tasks.delete_ignore_object_notification.apply_async') def test_create_ignore_obj_notification_api(self, mocked_task): From e6cbb9cd8cc00ba26e1ffbabd574256f4fac381c Mon Sep 17 00:00:00 2001 From: Dhanus Date: Wed, 17 Jul 2024 20:28:32 +0530 Subject: [PATCH 04/74] [refactor] URL routes --- openwisp_notifications/api/urls.py | 37 +++++++++++++++++++----------- openwisp_notifications/urls.py | 4 +--- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/openwisp_notifications/api/urls.py b/openwisp_notifications/api/urls.py index 5ecf2051..9ee729b7 100644 --- a/openwisp_notifications/api/urls.py +++ b/openwisp_notifications/api/urls.py @@ -9,31 +9,40 @@ def get_api_urls(api_views=None): if not api_views: api_views = views return [ - path('', views.notifications_list, name='notifications_list'), - path('read/', views.notifications_read_all, name='notifications_read_all'), - path('/', views.notification_detail, name='notification_detail'), + path('notification/', views.notifications_list, name='notifications_list'), path( - '/redirect/', - views.notification_read_redirect, - name='notification_read_redirect', + 'notification/read/', + views.notifications_read_all, + name='notifications_read_all', ), path( - 'user-setting/', - views.notification_setting_list, - name='notification_setting_list', + 'notification//', + views.notification_detail, + name='notification_detail', ), path( - 'user-setting//', - views.notification_setting, - name='notification_setting', + 'notification//redirect/', + views.notification_read_redirect, + name='notification_read_redirect', ), + # WIP + # path( + # 'user/user-setting/', + # views.notification_setting_list, + # name='notification_setting_list', + # ), + # path( + # 'user/user-setting//', + # views.notification_setting, + # name='notification_setting', + # ), path( - 'ignore/', + 'notification/ignore/', views.ignore_object_notification_list, name='ignore_object_notification_list', ), path( - 'ignore////', + 'notification/ignore////', views.ignore_object_notification, name='ignore_object_notification', ), diff --git a/openwisp_notifications/urls.py b/openwisp_notifications/urls.py index efc6d2ba..04a8f9d7 100644 --- a/openwisp_notifications/urls.py +++ b/openwisp_notifications/urls.py @@ -9,9 +9,7 @@ def get_urls(api_views=None, social_views=None): Arguments: api_views(optional): views for Notifications API """ - urls = [ - path('api/v1/notifications/notification/', include(get_api_urls(api_views))) - ] + urls = [path('api/v1/notifications/', include(get_api_urls(api_views)))] return urls From 531acdadb7f806718a9e2f230ba697fda3a72e5a Mon Sep 17 00:00:00 2001 From: Dhanus Date: Fri, 19 Jul 2024 14:39:46 +0530 Subject: [PATCH 05/74] [chore] Update tests --- openwisp_notifications/tests/test_api.py | 27 ++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index 150f777a..ce85c8e8 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -672,20 +672,21 @@ def _unread_notification(notification): ) def test_organization_notification_setting_update(self): - org = Organization.objects.first() - url = self._get_path('organization_notification_setting', self.admin.pk, org.pk) - response = self.client.post(url, data={'web': True, 'email': True}) - self.assertEqual(response.status_code, 200) - notification_setting = NotificationSetting.objects.filter( - user=self.admin, organization_id=org.pk - ).first() - self.assertTrue(notification_setting.web, True) - self.assertTrue(notification_setting.email, True) - - def test_admin_user_organization_notification_setting_update(self): tester = self._create_user() org = Organization.objects.first() + with self.subTest('Test for current user'): + url = self._get_path( + 'organization_notification_setting', self.admin.pk, org.pk + ) + response = self.client.post(url, data={'web': True, 'email': True}) + self.assertEqual(response.status_code, 200) + notification_setting = NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk + ).first() + self.assertTrue(notification_setting.web, True) + self.assertTrue(notification_setting.email, True) + with self.subTest('Test for admin user'): url = self._get_path( 'organization_notification_setting', self.admin.pk, org.pk @@ -700,9 +701,9 @@ def test_admin_user_organization_notification_setting_update(self): with self.subTest('Test for non-admin user'): self.client.force_login(tester) - url = self._get_path('organization_notification_setting', tester.pk, org.pk) + url = self._get_path('organization_notification_setting', self.admin.pk, org.pk) response = self.client.post(url, data={'web': True, 'email': True}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) @patch('openwisp_notifications.tasks.delete_ignore_object_notification.apply_async') def test_create_ignore_obj_notification_api(self, mocked_task): From 2dfc5a80b5fe254096320f7f2073dc8ef17b6f0f Mon Sep 17 00:00:00 2001 From: Dhanus Date: Fri, 19 Jul 2024 21:00:33 +0530 Subject: [PATCH 06/74] [chore] Add Global Notification --- openwisp_notifications/api/urls.py | 25 ++++++----- openwisp_notifications/api/views.py | 26 ++++++++++- openwisp_notifications/base/models.py | 2 + openwisp_notifications/handlers.py | 11 +++++ ..._alter_notificationsetting_organization.py | 25 +++++++++++ openwisp_notifications/tests/test_api.py | 44 ++++++++++++++++++- .../tests/test_notifications.py | 22 ++++++++++ ..._alter_notificationsetting_organization.py | 25 +++++++++++ 8 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py create mode 100644 tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py diff --git a/openwisp_notifications/api/urls.py b/openwisp_notifications/api/urls.py index 9ee729b7..18bcc2eb 100644 --- a/openwisp_notifications/api/urls.py +++ b/openwisp_notifications/api/urls.py @@ -26,16 +26,16 @@ def get_api_urls(api_views=None): name='notification_read_redirect', ), # WIP - # path( - # 'user/user-setting/', - # views.notification_setting_list, - # name='notification_setting_list', - # ), - # path( - # 'user/user-setting//', - # views.notification_setting, - # name='notification_setting', - # ), + path( + 'user/user-setting/', + views.notification_setting_list, + name='notification_setting_list', + ), + path( + 'user/user-setting//', + views.notification_setting, + name='notification_setting', + ), path( 'notification/ignore/', views.ignore_object_notification_list, @@ -51,4 +51,9 @@ def get_api_urls(api_views=None): views.organization_notification_setting, name='organization_notification_setting', ), + path( + 'user//preference/', + views.notification_preference, + name='notification_preference', + ), ] diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index 0fe787d6..3cf6a48a 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -215,8 +215,29 @@ def post(self, request, user_id, organization_id): for notification_setting in notification_settings: serializer.update(notification_setting, serializer.validated_data) return Response(status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class NotificationPreferenceView(GenericAPIView): + permission_classes = [IsAuthenticated, IsAuthenticatedToUpdateNotificationSetting] + serializer_class = NotificationSettingUpdateSerializer + + def post(self, request, user_id): + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + email = serializer.validated_data.get('email') + web = serializer.validated_data.get('web') + ( + notification_settings, + created, + ) = NotificationSetting.objects.update_or_create( + user_id=user_id, + organization=None, + type=None, + defaults={'email': email, 'web': web}, + ) + return Response(status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) notifications_list = NotificationListView.as_view() @@ -228,3 +249,4 @@ def post(self, request, user_id, organization_id): organization_notification_setting = OrganizationNotificationSettingView.as_view() ignore_object_notification_list = IgnoreObjectNotificationListView.as_view() ignore_object_notification = IgnoreObjectNotificationView.as_view() +notification_preference = NotificationPreferenceView.as_view() diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index f4e2fd55..59402a13 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -252,6 +252,8 @@ class AbstractNotificationSetting(UUIDModel): organization = models.ForeignKey( get_model_name('openwisp_users', 'Organization'), on_delete=models.CASCADE, + null=True, + blank=True, ) web = models.BooleanField( _('web notifications'), null=True, blank=True, help_text=_(_RECEIVE_HELP) diff --git a/openwisp_notifications/handlers.py b/openwisp_notifications/handlers.py index 67da6898..b8e9bbe7 100644 --- a/openwisp_notifications/handlers.py +++ b/openwisp_notifications/handlers.py @@ -170,6 +170,17 @@ def send_email_notification(sender, instance, created, **kwargs): return # Get email preference of user for this type of notification. target_org = getattr(getattr(instance, 'target', None), 'organization_id', None) + + # Check for global notification setting + try: + notification_setting = instance.recipient.notificationsetting_set.get( + organization=None, type=None + ) + if not notification_setting.email_notification: + return + except NotificationSetting.DoesNotExist: + pass + if instance.type and target_org: try: notification_setting = instance.recipient.notificationsetting_set.get( diff --git a/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py new file mode 100644 index 00000000..67f59942 --- /dev/null +++ b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2024-06-18 13:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("openwisp_users", "0020_populate_password_updated_field"), + ("openwisp_notifications", "0007_notificationsetting_deleted"), + ] + + operations = [ + migrations.AlterField( + model_name="notificationsetting", + name="organization", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="openwisp_users.organization", + ), + ), + ] diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index ce85c8e8..f5c0d72d 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -701,7 +701,49 @@ def test_organization_notification_setting_update(self): with self.subTest('Test for non-admin user'): self.client.force_login(tester) - url = self._get_path('organization_notification_setting', self.admin.pk, org.pk) + url = self._get_path( + 'organization_notification_setting', + self.admin.pk, + org.pk, + ) + response = self.client.post(url, data={'web': True, 'email': True}) + self.assertEqual(response.status_code, 403) + + def test_notification_preference_update(self): + tester = self._create_user() + + with self.subTest('Test for current user'): + url = self._get_path( + 'notification_preference', + self.admin.pk, + ) + response = self.client.post(url, data={'web': True, 'email': True}) + self.assertEqual(response.status_code, 200) + notification_setting = NotificationSetting.objects.get( + user=self.admin, organization_id=None, type=None + ) + self.assertTrue(notification_setting.web, True) + self.assertTrue(notification_setting.email, True) + + with self.subTest('Test for admin user'): + url = self._get_path( + 'notification_preference', + tester.pk, + ) + response = self.client.post(url, data={'web': True, 'email': True}) + self.assertEqual(response.status_code, 200) + notification_setting = NotificationSetting.objects.get( + user=tester, organization_id=None, type=None + ) + self.assertTrue(notification_setting.web, True) + self.assertTrue(notification_setting.email, True) + + with self.subTest('Test for non-admin user'): + self.client.force_login(tester) + url = self._get_path( + 'notification_preference', + self.admin.pk, + ) response = self.client.post(url, data={'web': True, 'email': True}) self.assertEqual(response.status_code, 403) diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index 555eb060..8f157b7c 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -779,6 +779,28 @@ def test_notification_type_web_notification_setting_true(self): self._create_notification() self.assertEqual(notification_queryset.count(), 0) + @mock_notification_types + def test_global_email_notification_setting(self): + with self.subTest('Test email global preference is "False"'): + NotificationSetting.objects.update_or_create( + user=self.admin, + organization=None, + type=None, + defaults={'email': False, 'web': False}, + ) + self._create_notification() + self.assertEqual(len(mail.outbox), 0) + + with self.subTest('Test email global preference is "True"'): + NotificationSetting.objects.update_or_create( + user=self.admin, + organization=None, + type=None, + defaults={'email': True, 'web': True}, + ) + self._create_notification() + self.assertEqual(len(mail.outbox), 1) + @mock_notification_types def test_notification_type_web_notification_setting_false(self): target_obj = self._get_org_user() diff --git a/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py new file mode 100644 index 00000000..0ca0ff27 --- /dev/null +++ b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2024-06-20 13:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("openwisp_users", "0020_populate_password_updated_field"), + ("sample_notifications", "0002_testapp"), + ] + + operations = [ + migrations.AlterField( + model_name="notificationsetting", + name="organization", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="openwisp_users.organization", + ), + ), + ] From 215118b4594cdebf3b7d0a4bb34188031436a73f Mon Sep 17 00:00:00 2001 From: Dhanus Date: Thu, 25 Jul 2024 10:45:32 +0530 Subject: [PATCH 07/74] [chore] Global Notification Preference changes --- openwisp_notifications/api/filters.py | 5 +++ openwisp_notifications/api/views.py | 3 ++ openwisp_notifications/handlers.py | 10 ------ openwisp_notifications/tasks.py | 13 ++++++- openwisp_notifications/tests/test_api.py | 24 +++++++++---- .../tests/test_notifications.py | 34 ++++--------------- 6 files changed, 43 insertions(+), 46 deletions(-) diff --git a/openwisp_notifications/api/filters.py b/openwisp_notifications/api/filters.py index b67a5604..0f785fde 100644 --- a/openwisp_notifications/api/filters.py +++ b/openwisp_notifications/api/filters.py @@ -8,3 +8,8 @@ class NotificationSettingFilter(OrganizationMembershipFilter): class Meta(OrganizationMembershipFilter.Meta): model = NotificationSetting fields = OrganizationMembershipFilter.Meta.fields + ['type'] + + @property + def qs(self): + parent_qs = super().qs + return parent_qs.exclude(organization__isnull=True, type__isnull=True) diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index 3cf6a48a..8578b45c 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -236,6 +236,9 @@ def post(self, request, user_id): type=None, defaults={'email': email, 'web': web}, ) + NotificationSetting.objects.filter(user_id=user_id).update( + email=email, web=web + ) return Response(status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/openwisp_notifications/handlers.py b/openwisp_notifications/handlers.py index b8e9bbe7..f247e67a 100644 --- a/openwisp_notifications/handlers.py +++ b/openwisp_notifications/handlers.py @@ -171,16 +171,6 @@ def send_email_notification(sender, instance, created, **kwargs): # Get email preference of user for this type of notification. target_org = getattr(getattr(instance, 'target', None), 'organization_id', None) - # Check for global notification setting - try: - notification_setting = instance.recipient.notificationsetting_set.get( - organization=None, type=None - ) - if not notification_setting.email_notification: - return - except NotificationSetting.DoesNotExist: - pass - if instance.type and target_org: try: notification_setting = instance.recipient.notificationsetting_set.get( diff --git a/openwisp_notifications/tasks.py b/openwisp_notifications/tasks.py index f1f6cdc4..e57fced6 100644 --- a/openwisp_notifications/tasks.py +++ b/openwisp_notifications/tasks.py @@ -82,10 +82,21 @@ def delete_old_notifications(days): # Following tasks updates notification settings in database. # 'ns' is short for notification_setting def create_notification_settings(user, organizations, notification_types): + global_setting, _ = NotificationSetting.objects.get_or_create( + user=user, organization=None, type=None, defaults={'email': True, 'web': True} + ) + for type in notification_types: for org in organizations: NotificationSetting.objects.update_or_create( - defaults={'deleted': False}, user=user, type=type, organization=org + defaults={ + 'deleted': False, + 'email': global_setting.email, + 'web': global_setting.web, + }, + user=user, + type=type, + organization=org, ) diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index f5c0d72d..1fa30dc3 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -260,7 +260,9 @@ def test_bearer_authentication(self, mocked_test): notify.send(sender=self.admin, type='default', target=self._get_org_user()) n = Notification.objects.first() notification_setting = NotificationSetting.objects.first() - notification_setting_count = NotificationSetting.objects.count() + notification_setting_count = NotificationSetting.objects.exclude( + organization__isnull=True + ).count() token = self._obtain_auth_token(username='admin', password='tester') with self.subTest('Test listing all notifications'): @@ -499,7 +501,9 @@ def test_obsolete_notifications_busy_worker(self, mocked_task): def test_notification_setting_list_api(self): self._create_org_user(is_admin=True) - number_of_settings = NotificationSetting.objects.filter(user=self.admin).count() + number_of_settings = NotificationSetting.objects.filter( + user=self.admin, organization__isnull=False + ).count() url = self._get_path('notification_setting_list') with self.subTest('Test notification setting list view'): @@ -551,15 +555,17 @@ def test_notification_setting_list_api(self): self.assertEqual(response.status_code, 200) notification_setting = response.data['results'][0] self.assertIn('id', notification_setting) - self.assertIsNone(notification_setting['web']) - self.assertIsNone(notification_setting['email']) + self.assertTrue(notification_setting['web']) + self.assertTrue(notification_setting['email']) self.assertIn('organization', notification_setting) def test_list_notification_setting_filtering(self): url = self._get_path('notification_setting_list') with self.subTest('Test listing notification setting without filters'): - count = NotificationSetting.objects.count() + count = NotificationSetting.objects.exclude( + organization__isnull=True + ).count() response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data['results']), count) @@ -593,7 +599,9 @@ def test_list_notification_setting_filtering(self): self.assertEqual(ns['type'], 'default') def test_retreive_notification_setting_api(self): - notification_setting = NotificationSetting.objects.first() + notification_setting = NotificationSetting.objects.exclude( + organization__isnull=True + ).first() with self.subTest('Test for non-existing notification setting'): url = self._get_path('notification_setting', uuid.uuid4()) @@ -614,7 +622,9 @@ def test_retreive_notification_setting_api(self): self.assertEqual(data['email'], notification_setting.email) def test_update_notification_setting_api(self): - notification_setting = NotificationSetting.objects.first() + notification_setting = NotificationSetting.objects.exclude( + organization__isnull=True + ).first() update_data = {'web': False} with self.subTest('Test for non-existing notification setting'): diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index 8f157b7c..cbc22454 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -132,7 +132,7 @@ def test_superuser_notifications_disabled(self): organization_id=target_obj.organization.pk, type='default', ) - self.assertEqual(notification_preference.email, None) + self.assertTrue(notification_preference.email) notification_preference.web = False notification_preference.save() notification_preference.refresh_from_db() @@ -743,7 +743,7 @@ def test_notification_type_email_notification_setting_false(self): with self.subTest('Test user email preference not defined'): self._create_notification() - self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(mail.outbox), 1) with self.subTest('Test user email preference is "True"'): NotificationSetting.objects.filter( @@ -751,7 +751,7 @@ def test_notification_type_email_notification_setting_false(self): type='test_type', ).update(email=True) self._create_notification() - self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox), 2) @mock_notification_types def test_notification_type_web_notification_setting_true(self): @@ -779,28 +779,6 @@ def test_notification_type_web_notification_setting_true(self): self._create_notification() self.assertEqual(notification_queryset.count(), 0) - @mock_notification_types - def test_global_email_notification_setting(self): - with self.subTest('Test email global preference is "False"'): - NotificationSetting.objects.update_or_create( - user=self.admin, - organization=None, - type=None, - defaults={'email': False, 'web': False}, - ) - self._create_notification() - self.assertEqual(len(mail.outbox), 0) - - with self.subTest('Test email global preference is "True"'): - NotificationSetting.objects.update_or_create( - user=self.admin, - organization=None, - type=None, - defaults={'email': True, 'web': True}, - ) - self._create_notification() - self.assertEqual(len(mail.outbox), 1) - @mock_notification_types def test_notification_type_web_notification_setting_false(self): target_obj = self._get_org_user() @@ -819,7 +797,7 @@ def test_notification_type_web_notification_setting_false(self): with self.subTest('Test user web preference not defined'): self._create_notification() - self.assertEqual(notification_queryset.count(), 0) + self.assertEqual(notification_queryset.count(), 1) with self.subTest('Test user email preference is "True"'): notification_setting = NotificationSetting.objects.get( @@ -828,14 +806,14 @@ def test_notification_type_web_notification_setting_false(self): notification_setting.email = True notification_setting.save() notification_setting.refresh_from_db() - self.assertFalse(notification_setting.email) + self.assertTrue(notification_setting.email) with self.subTest('Test user web preference is "True"'): NotificationSetting.objects.filter( user=self.admin, type='test_type' ).update(web=True) self._create_notification() - self.assertEqual(notification_queryset.count(), 1) + self.assertEqual(notification_queryset.count(), 2) @mock_notification_types def test_notification_type_email_web_notification_defaults(self): From 90221ca47140264c325d16990976731ade0844cd Mon Sep 17 00:00:00 2001 From: Dhanus Date: Thu, 1 Aug 2024 19:20:39 +0530 Subject: [PATCH 08/74] [feat] Notification Settings Page --- openwisp_notifications/api/urls.py | 20 ++- openwisp_notifications/api/views.py | 9 ++ openwisp_notifications/base/views.py | 10 ++ .../openwisp-notifications/css/settings.css | 62 ++++++++ .../openwisp-notifications/js/settings.js | 149 ++++++++++++++++++ .../openwisp_notifications/settings.html | 41 +++++ openwisp_notifications/tests/test_api.py | 116 +++++++++++++- openwisp_notifications/urls.py | 7 +- 8 files changed, 407 insertions(+), 7 deletions(-) create mode 100644 openwisp_notifications/base/views.py create mode 100644 openwisp_notifications/static/openwisp-notifications/css/settings.css create mode 100644 openwisp_notifications/static/openwisp-notifications/js/settings.js create mode 100644 openwisp_notifications/templates/openwisp_notifications/settings.html diff --git a/openwisp_notifications/api/urls.py b/openwisp_notifications/api/urls.py index 18bcc2eb..482c8001 100644 --- a/openwisp_notifications/api/urls.py +++ b/openwisp_notifications/api/urls.py @@ -25,16 +25,15 @@ def get_api_urls(api_views=None): views.notification_read_redirect, name='notification_read_redirect', ), - # WIP path( - 'user/user-setting/', + 'user//user-setting/', views.notification_setting_list, - name='notification_setting_list', + name='user_notification_setting_list', ), path( - 'user/user-setting//', + 'user//user-setting//', views.notification_setting, - name='notification_setting', + name='user_notification_setting', ), path( 'notification/ignore/', @@ -56,4 +55,15 @@ def get_api_urls(api_views=None): views.notification_preference, name='notification_preference', ), + # DEPRECATED + path( + 'user/user-setting/', + views.notification_setting_list, + name='notification_setting_list', + ), + path( + 'user/user-setting//', + views.notification_setting, + name='notification_setting', + ), ] diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index 8578b45c..2f218b14 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponseRedirect from django.urls import reverse from django_filters.rest_framework import DjangoFilterBackend @@ -123,6 +124,14 @@ class BaseNotificationSettingView(GenericAPIView): def get_queryset(self): if getattr(self, 'swagger_fake_view', False): return NotificationSetting.objects.none() # pragma: no cover + + user_id = self.kwargs.get('user_id') + + if user_id: + if not (self.request.user.id == user_id or self.request.user.is_staff): + raise PermissionDenied() + return NotificationSetting.objects.filter(user_id=user_id) + return NotificationSetting.objects.filter(user=self.request.user) diff --git a/openwisp_notifications/base/views.py b/openwisp_notifications/base/views.py new file mode 100644 index 00000000..6a188e10 --- /dev/null +++ b/openwisp_notifications/base/views.py @@ -0,0 +1,10 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import TemplateView + + +class NotificationSettingPage(LoginRequiredMixin, TemplateView): + template_name = 'openwisp_notifications/settings.html' + login_url = '/admin/login/' + + +notifiation_setting_page = NotificationSettingPage.as_view() diff --git a/openwisp_notifications/static/openwisp-notifications/css/settings.css b/openwisp_notifications/static/openwisp-notifications/css/settings.css new file mode 100644 index 00000000..90933110 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/css/settings.css @@ -0,0 +1,62 @@ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} +h2 { + margin-bottom: 10px; +} +.global-settings { + margin-bottom: 20px; +} +.org-panel { + background-color: #fff; + border: 1px solid #ddd; +} +.org-header { + background-color: #e0e0e0; + padding: 10px; + font-weight: bold; + display: flex; + justify-content: space-between; + cursor: pointer; +} +.org-content { + padding: 10px; + display: none; +} +.org-content.active { + display: block; +} +table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} +th, +td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #ddd; +} +th { + background-color: #f2f2f2; +} +th:not(:last-child), +td:not(:last-child) { + border-right: 1px solid #ddd; +} +th:not(:first-child), +td:not(:first-child) { + text-align: center; +} +.checkbox { + width: 15px; + height: 15px; + text-align: center; +} +.no-settings { + padding: 10px; + text-align: center; + color: #666; +} diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js new file mode 100644 index 00000000..7ee24481 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -0,0 +1,149 @@ +'use strict'; + +(function ($) { + $(document).ready(function () { + fetchNotificationSettings(); + }); + + function fetchNotificationSettings() { + $.ajax({ + url: "/api/v1/notifications/user/user-setting", + method: "GET", + success: function (data) { + const groupedData = groupByOrganization(data.results); + renderNotificationSettings(groupedData); + initializeEventListeners(); + }, + }); + } + + function groupByOrganization(settings) { + const grouped = {}; + settings.forEach((setting) => { + if (!grouped[setting.organization_name]) { + grouped[setting.organization_name] = []; + } + grouped[setting.organization_name].push(setting); + }); + return grouped; + } + + function renderNotificationSettings(data) { + const orgPanelsContainer = $("#org-panels"); + orgPanelsContainer.empty(); // Clear existing content + + Object.keys(data) + .sort() + .forEach((orgName) => { + const orgSettings = data[orgName].sort((a, b) => + a.type.localeCompare(b.type) + ); + const orgPanel = $(` +
+
+ ${orgName} +
+
+
+ `); + const orgContent = orgPanel.find(".org-content"); + + if (orgSettings.length > 0) { + const table = $(` + + + + + + +
Settings + Email + + Web +
+ `); + orgSettings.forEach((setting) => { + const row = $(` + + ${setting.type} + + + + `); + table.append(row); + }); + orgContent.append(table); + updateMainCheckboxes(table); + } else { + orgContent.append(`
No settings available for this organization
`); + } + + orgPanelsContainer.append(orgPanel); + }); + } + + function updateMainCheckboxes(table) { + const emailCheckboxes = table.find('.email-checkbox'); + const webCheckboxes = table.find('.web-checkbox'); + const emailMainCheckbox = table.find('.main-checkbox[data-column="email"]'); + const webMainCheckbox = table.find('.main-checkbox[data-column="web"]'); + + emailMainCheckbox.prop('checked', emailCheckboxes.length === emailCheckboxes.filter(':checked').length); + webMainCheckbox.prop('checked', webCheckboxes.length === webCheckboxes.filter(':checked').length); + } + + function initializeEventListeners() { + $(document).on('click', '.org-header', function () { + const toggle = $(this).find(".toggle"); + toggle.text(toggle.text() === "▼" ? "▲" : "▼"); + $(this).next(".org-content").toggleClass("active"); + }); + + $(document).on('change', '.main-checkbox', function () { + const column = $(this).data("column"); + $(this).closest("table").find(`.${column}-checkbox`).prop("checked", $(this).prop("checked")); + showToast('success', 'Settings updated successfully.'); + }); + + $(document).on('change', '.email-checkbox, .web-checkbox', function () { + const column = $(this).hasClass("email-checkbox") ? "email" : "web"; + const mainCheckbox = $(this).closest("table").find(`.main-checkbox[data-column="${column}"]`); + const checkboxes = $(this).closest("table").find(`.${column}-checkbox`); + mainCheckbox.prop("checked", checkboxes.length === checkboxes.filter(":checked").length); + showToast('success', 'Settings updated successfully.'); + }); + + $("#global-email, #global-web").change(function () { + const isEmail = $(this).attr("id") === "global-email"; + const columnClass = isEmail ? "email-checkbox" : "web-checkbox"; + $(`.${columnClass}`).prop("checked", $(this).prop("checked")); + $(`.main-checkbox[data-column="${isEmail ? "email" : "web"}"]`).prop("checked", $(this).prop("checked")); + showToast('success', 'Global settings updated successfully.'); + }); + } + + function showToast(level, message) { + const toast = $(` +
+
+
+
+ ${message} +
+
+ `); + $('.ow-notification-toast-wrapper').prepend(toast); + // toast.slideDown('slow', function () { + // setTimeout(function () { + // toast.slideUp('slow', function () { + // toast.remove(); + // }); + // }, 3000); + // }); + + $(document).on('click', '.ow-notification-toast .ow-notify-close.btn', function () { + toast.remove(); + }); + } + +})(django.jQuery); diff --git a/openwisp_notifications/templates/openwisp_notifications/settings.html b/openwisp_notifications/templates/openwisp_notifications/settings.html new file mode 100644 index 00000000..42280ca5 --- /dev/null +++ b/openwisp_notifications/templates/openwisp_notifications/settings.html @@ -0,0 +1,41 @@ +{% extends "admin/base_site.html" %} + +{% load i18n %} +{% load static %} + +{% block title %}{% trans "Notification Settings" %}{% endblock %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock extrastyle %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

Notification Preferences

+ +
+ Global Settings: + + +
+ +
+ +
+
+{% endblock content %} + +{% block footer %} + {{ block.super }} + {% if request.user.is_authenticated %} + + {% endif %} +{% endblock footer %} diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index 1fa30dc3..83cdd941 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -561,6 +561,7 @@ def test_notification_setting_list_api(self): def test_list_notification_setting_filtering(self): url = self._get_path('notification_setting_list') + tester = self._create_user() with self.subTest('Test listing notification setting without filters'): count = NotificationSetting.objects.exclude( @@ -598,10 +599,33 @@ def test_list_notification_setting_filtering(self): ns = response.data['results'].pop() self.assertEqual(ns['type'], 'default') + with self.subTest('Test filtering by user_id as admin'): + user_url = self._get_path('user_notification_setting_list', tester.pk) + response = self.client.get(user_url) + self.assertEqual(response.status_code, 200) + + with self.subTest('Test with user_id by user_id as the same user'): + self.client.force_login(tester) + user_url = self._get_path('user_notification_setting_list', tester.pk) + response = self.client.get(user_url) + self.assertEqual(response.status_code, 200) + + with self.subTest('Test with user_id as a different non-admin user'): + self.client.force_login(tester) + user_url = self._get_path('user_notification_setting_list', self.admin.pk) + response = self.client.get(user_url) + self.assertEqual(response.status_code, 403) + def test_retreive_notification_setting_api(self): notification_setting = NotificationSetting.objects.exclude( organization__isnull=True ).first() + tester = self._create_user() + tester_notification_setting = NotificationSetting.objects.create( + user=tester, + type='default', + organization=Organization.objects.first(), + ) with self.subTest('Test for non-existing notification setting'): url = self._get_path('notification_setting', uuid.uuid4()) @@ -621,10 +645,49 @@ def test_retreive_notification_setting_api(self): self.assertEqual(data['web'], notification_setting.web) self.assertEqual(data['email'], notification_setting.email) + with self.subTest( + 'Test retrieving details for existing notification setting as admin' + ): + url = self._get_path( + 'user_notification_setting', tester.pk, tester_notification_setting.pk + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.data + self.assertEqual(data['id'], str(tester_notification_setting.id)) + + with self.subTest( + 'Test retrieving details for existing notification setting as the same user' + ): + self.client.force_login(tester) + url = self._get_path( + 'user_notification_setting', tester.pk, tester_notification_setting.pk + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.data + self.assertEqual(data['id'], str(tester_notification_setting.id)) + + with self.subTest( + 'Test retrieving details for existing notification setting as different non-admin user' + ): + self.client.force_login(tester) + url = self._get_path( + 'user_notification_setting', self.admin.pk, notification_setting.pk + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + def test_update_notification_setting_api(self): notification_setting = NotificationSetting.objects.exclude( organization__isnull=True ).first() + tester = self._create_user() + tester_notification_setting = NotificationSetting.objects.create( + user=tester, + type='default', + organization=Organization.objects.first(), + ) update_data = {'web': False} with self.subTest('Test for non-existing notification setting'): @@ -632,7 +695,7 @@ def test_update_notification_setting_api(self): response = self.client.put(url, data=update_data) self.assertEqual(response.status_code, 404) - with self.subTest('Test retrieving details for existing notification setting'): + with self.subTest('Test updating details for existing notification setting'): url = self._get_path( 'notification_setting', notification_setting.pk, @@ -648,6 +711,57 @@ def test_update_notification_setting_api(self): self.assertEqual(data['web'], notification_setting.web) self.assertEqual(data['email'], notification_setting.email) + with self.subTest( + 'Test updating details for existing notification setting as admin' + ): + url = self._get_path( + 'user_notification_setting', tester.pk, tester_notification_setting.pk + ) + response = self.client.put( + url, update_data, content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + data = response.data + tester_notification_setting.refresh_from_db() + self.assertEqual(data['id'], str(tester_notification_setting.id)) + self.assertEqual( + data['organization'], tester_notification_setting.organization.pk + ) + self.assertEqual(data['web'], tester_notification_setting.web) + self.assertEqual(data['email'], tester_notification_setting.email) + + with self.subTest( + 'Test updating details for existing notification setting as the same user' + ): + self.client.force_login(tester) + url = self._get_path( + 'user_notification_setting', tester.pk, tester_notification_setting.pk + ) + response = self.client.put( + url, update_data, content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + data = response.data + tester_notification_setting.refresh_from_db() + self.assertEqual(data['id'], str(tester_notification_setting.id)) + self.assertEqual( + data['organization'], tester_notification_setting.organization.pk + ) + self.assertEqual(data['web'], tester_notification_setting.web) + self.assertEqual(data['email'], tester_notification_setting.email) + + with self.subTest( + 'Test updating details for existing notification setting as a different non-admin user' + ): + self.client.force_login(tester) + url = self._get_path( + 'user_notification_setting', self.admin.pk, notification_setting.pk + ) + response = self.client.put( + url, update_data, content_type='application/json' + ) + self.assertEqual(response.status_code, 403) + def test_notification_redirect_api(self): def _unread_notification(notification): notification.unread = True diff --git a/openwisp_notifications/urls.py b/openwisp_notifications/urls.py index 04a8f9d7..acc1043f 100644 --- a/openwisp_notifications/urls.py +++ b/openwisp_notifications/urls.py @@ -1,6 +1,7 @@ from django.urls import include, path from .api.urls import get_api_urls +from .base.views import notifiation_setting_page def get_urls(api_views=None, social_views=None): @@ -9,7 +10,11 @@ def get_urls(api_views=None, social_views=None): Arguments: api_views(optional): views for Notifications API """ - urls = [path('api/v1/notifications/', include(get_api_urls(api_views)))] + urls = [ + path('api/v1/notifications/', include(get_api_urls(api_views))), + path('notifications/settings/', notifiation_setting_page), + path('notifications/user//settings/', notifiation_setting_page), + ] return urls From 64fe8732279ccd711eefae00a32f33da7c20ccca Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 9 Jul 2024 17:12:12 +0530 Subject: [PATCH 09/74] [ci] Run builds for gsoc24 branch --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07404263..d31d835e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,10 +5,12 @@ on: branches: - master - dev + - gsoc24 pull_request: branches: - master - dev + - gsoc24 jobs: From fdb8fdfe51852eb7ef3749fb35d8404cc487d671 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Fri, 2 Aug 2024 20:06:55 +0530 Subject: [PATCH 10/74] [qa] Check fixes --- .../migrations/0008_alter_notificationsetting_organization.py | 1 - openwisp_notifications/tests/test_notifications.py | 2 +- .../migrations/0003_alter_notificationsetting_organization.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py index 67f59942..45930143 100644 --- a/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py +++ b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("openwisp_users", "0020_populate_password_updated_field"), ("openwisp_notifications", "0007_notificationsetting_deleted"), diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index cbc22454..c0f102a4 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -751,7 +751,7 @@ def test_notification_type_email_notification_setting_false(self): type='test_type', ).update(email=True) self._create_notification() - self.assertEqual(len(mail.outbox), 2) + self.assertEqual(len(mail.outbox), 1) @mock_notification_types def test_notification_type_web_notification_setting_true(self): diff --git a/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py index 0ca0ff27..7ab64f70 100644 --- a/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py +++ b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("openwisp_users", "0020_populate_password_updated_field"), ("sample_notifications", "0002_testapp"), From 7c5cbebe62bf944f5cca0274f12b91b40c52103c Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sat, 3 Aug 2024 20:48:50 +0530 Subject: [PATCH 11/74] [chore] Notification settings page with breadcrumbs --- openwisp_notifications/base/views.py | 34 ++++++++++++- .../openwisp-notifications/js/settings.js | 29 +++++++---- .../openwisp_notifications/settings.html | 51 +++++++++++-------- openwisp_notifications/urls.py | 12 ++++- 4 files changed, 91 insertions(+), 35 deletions(-) diff --git a/openwisp_notifications/base/views.py b/openwisp_notifications/base/views.py index 6a188e10..a7bf7ef8 100644 --- a/openwisp_notifications/base/views.py +++ b/openwisp_notifications/base/views.py @@ -1,10 +1,40 @@ -from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.http import Http404 from django.views.generic import TemplateView +User = get_user_model() -class NotificationSettingPage(LoginRequiredMixin, TemplateView): + +class NotificationSettingPage(LoginRequiredMixin, UserPassesTestMixin, TemplateView): template_name = 'openwisp_notifications/settings.html' login_url = '/admin/login/' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user_id = self.kwargs.get('pk') + + if user_id: + try: + user = User.objects.get(pk=user_id) + # Only admin should access other users settings + if not self.request.user.is_staff: + raise Http404("You do not have permission to access this page.") + except User.DoesNotExist: + raise Http404("User does not exist") + else: + user = self.request.user + + context['user'] = user + return context + + def test_func(self): + """ + This method ensures that only admins can access the view when a custom user ID is provided. + """ + if 'pk' in self.kwargs: + return self.request.user.is_staff + return True + notifiation_setting_page = NotificationSettingPage.as_view() diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index 7ee24481..f41d6a3b 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -2,18 +2,25 @@ (function ($) { $(document).ready(function () { - fetchNotificationSettings(); + const userId = $('.container').data('user-id'); + fetchNotificationSettings(userId); }); - function fetchNotificationSettings() { + function fetchNotificationSettings(userId) { + console.log("Fetching notification settings..."); + console.log(`/api/v1/notifications/user/${userId}/user-setting/`) $.ajax({ - url: "/api/v1/notifications/user/user-setting", + url: `/api/v1/notifications/user/${userId}/user-setting/`, method: "GET", success: function (data) { const groupedData = groupByOrganization(data.results); renderNotificationSettings(groupedData); initializeEventListeners(); }, + error: function (error) { + console.error("Error fetching notification settings:", error); + showToast('error', 'Error fetching notification settings. Please try again.'); + } }); } @@ -109,7 +116,7 @@ const column = $(this).hasClass("email-checkbox") ? "email" : "web"; const mainCheckbox = $(this).closest("table").find(`.main-checkbox[data-column="${column}"]`); const checkboxes = $(this).closest("table").find(`.${column}-checkbox`); - mainCheckbox.prop("checked", checkboxes.length === checkboxes.filter(":checked").length); + mainCheckbox.prop("checked", checkboxes.length === checkboxes.filter(':checked').length); showToast('success', 'Settings updated successfully.'); }); @@ -133,13 +140,13 @@ `); $('.ow-notification-toast-wrapper').prepend(toast); - // toast.slideDown('slow', function () { - // setTimeout(function () { - // toast.slideUp('slow', function () { - // toast.remove(); - // }); - // }, 3000); - // }); + toast.slideDown('slow', function () { + setTimeout(function () { + toast.slideUp('slow', function () { + toast.remove(); + }); + }, 3000); + }); $(document).on('click', '.ow-notification-toast .ow-notify-close.btn', function () { toast.remove(); diff --git a/openwisp_notifications/templates/openwisp_notifications/settings.html b/openwisp_notifications/templates/openwisp_notifications/settings.html index 42280ca5..466201d8 100644 --- a/openwisp_notifications/templates/openwisp_notifications/settings.html +++ b/openwisp_notifications/templates/openwisp_notifications/settings.html @@ -3,34 +3,45 @@ {% load i18n %} {% load static %} -{% block title %}{% trans "Notification Settings" %}{% endblock %} +{% block title %} + {% trans "Notification Settings" %} +{% endblock %} {% block extrastyle %} -{{ block.super }} - + {{ block.super }} + {% endblock extrastyle %} {% block breadcrumbs %} - -{% endblock %} - -{% block content %} -
-

Notification Preferences

- -
- Global Settings: - - + +{% endblock breadcrumbs %} -
- +{% block content %} +
+

Notification Preferences

+
+ Global Settings: + + +
+
-
{% endblock content %} {% block footer %} diff --git a/openwisp_notifications/urls.py b/openwisp_notifications/urls.py index acc1043f..e5d2cfac 100644 --- a/openwisp_notifications/urls.py +++ b/openwisp_notifications/urls.py @@ -12,8 +12,16 @@ def get_urls(api_views=None, social_views=None): """ urls = [ path('api/v1/notifications/', include(get_api_urls(api_views))), - path('notifications/settings/', notifiation_setting_page), - path('notifications/user//settings/', notifiation_setting_page), + path( + 'notifications/settings/', + notifiation_setting_page, + name='notifications_settings', + ), + path( + 'notifications/user//settings/', + notifiation_setting_page, + name='user_notification_settings', + ), ] return urls From dfca656046211070768b49478d5752b3e74256d5 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sun, 4 Aug 2024 19:18:22 +0530 Subject: [PATCH 12/74] [chore] Notification settings update --- .../css/notifications.css | 2 +- .../openwisp-notifications/js/settings.js | 263 +++++++++++------- 2 files changed, 158 insertions(+), 107 deletions(-) diff --git a/openwisp_notifications/static/openwisp-notifications/css/notifications.css b/openwisp_notifications/static/openwisp-notifications/css/notifications.css index a8b27932..99d1c926 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/notifications.css +++ b/openwisp_notifications/static/openwisp-notifications/css/notifications.css @@ -113,7 +113,7 @@ float: right; position: relative; right: -2px; - bottom: -8px; + bottom: -3px; background-size: 9px; } .ow-notification-toast.info .icon { diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index f41d6a3b..d19af8b3 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -1,144 +1,196 @@ 'use strict'; (function ($) { + let isGlobalChange = false; + $(document).ready(function () { const userId = $('.container').data('user-id'); fetchNotificationSettings(userId); + initializeGlobalSettingsEventListener(userId); }); function fetchNotificationSettings(userId) { - console.log("Fetching notification settings..."); - console.log(`/api/v1/notifications/user/${userId}/user-setting/`) - $.ajax({ - url: `/api/v1/notifications/user/${userId}/user-setting/`, - method: "GET", - success: function (data) { - const groupedData = groupByOrganization(data.results); - renderNotificationSettings(groupedData); - initializeEventListeners(); - }, - error: function (error) { - console.error("Error fetching notification settings:", error); - showToast('error', 'Error fetching notification settings. Please try again.'); - } + $.getJSON(`/api/v1/notifications/user/${userId}/user-setting/`, function (data) { + const groupedData = groupBy(data.results, 'organization_name'); + renderNotificationSettings(groupedData); + initializeEventListeners(userId); + }).fail(function () { + showToast('error', 'Error fetching notification settings. Please try again.'); }); } - function groupByOrganization(settings) { - const grouped = {}; - settings.forEach((setting) => { - if (!grouped[setting.organization_name]) { - grouped[setting.organization_name] = []; - } - grouped[setting.organization_name].push(setting); - }); - return grouped; + function groupBy(array, key) { + return array.reduce((result, currentValue) => { + (result[currentValue[key]] = result[currentValue[key]] || []).push(currentValue); + return result; + }, {}); } function renderNotificationSettings(data) { - const orgPanelsContainer = $("#org-panels"); - orgPanelsContainer.empty(); // Clear existing content - - Object.keys(data) - .sort() - .forEach((orgName) => { - const orgSettings = data[orgName].sort((a, b) => - a.type.localeCompare(b.type) - ); - const orgPanel = $(` -
-
- ${orgName} -
-
-
- `); - const orgContent = orgPanel.find(".org-content"); - - if (orgSettings.length > 0) { - const table = $(` - - - - - - -
Settings - Email - - Web -
- `); - orgSettings.forEach((setting) => { - const row = $(` - - ${setting.type} - - - - `); - table.append(row); - }); - orgContent.append(table); - updateMainCheckboxes(table); - } else { - orgContent.append(`
No settings available for this organization
`); - } - - orgPanelsContainer.append(orgPanel); + const orgPanelsContainer = $("#org-panels").empty(); + Object.keys(data).sort().forEach(function(orgName) { + const orgSettings = data[orgName].sort(function(a, b) { + return a.type.localeCompare(b.type); }); + const orgPanel = $( + '
' + + '
' + orgName + '
' + + '
' + + '
' + ); + const orgContent = orgPanel.find(".org-content"); + if (orgSettings.length > 0) { + const table = $( + '' + + '' + + '' + + '' + + '' + + '' + + '
Settings Email Web
' + ); + orgSettings.forEach(function(setting) { + const row = $( + '' + + '' + setting.type + '' + + '' + + '' + + '' + ); + table.append(row); + }); + orgContent.append(table); + updateMainCheckboxes(table); + } else { + orgContent.append('
No settings available for this organization
'); + } + orgPanelsContainer.append(orgPanel); + }); } function updateMainCheckboxes(table) { - const emailCheckboxes = table.find('.email-checkbox'); - const webCheckboxes = table.find('.web-checkbox'); - const emailMainCheckbox = table.find('.main-checkbox[data-column="email"]'); - const webMainCheckbox = table.find('.main-checkbox[data-column="web"]'); - - emailMainCheckbox.prop('checked', emailCheckboxes.length === emailCheckboxes.filter(':checked').length); - webMainCheckbox.prop('checked', webCheckboxes.length === webCheckboxes.filter(':checked').length); + table.find('.main-checkbox').each(function () { + const column = $(this).data('column'); + const allChecked = table.find('.' + column + '-checkbox').length === table.find('.' + column + '-checkbox:checked').length; + $(this).prop('checked', allChecked); + }); } - function initializeEventListeners() { + function initializeEventListeners(userId) { $(document).on('click', '.org-header', function () { const toggle = $(this).find(".toggle"); toggle.text(toggle.text() === "▼" ? "▲" : "▼"); $(this).next(".org-content").toggleClass("active"); }); + $(document).on('change', '.email-checkbox, .web-checkbox', function () { + if (isGlobalChange) { + return; + } + updateIndividualSetting(userId, $(this)); + updateOrgLevelCheckboxes($(this).data('organization-id')); + }); + $(document).on('change', '.main-checkbox', function () { - const column = $(this).data("column"); - $(this).closest("table").find(`.${column}-checkbox`).prop("checked", $(this).prop("checked")); - showToast('success', 'Settings updated successfully.'); + if (isGlobalChange) { + return; + } + updateOrganizationSetting(userId, $(this)); + const table = $(this).closest('table'); + table.find('.' + $(this).data('column') + '-checkbox').prop('checked', $(this).is(':checked')); + updateMainCheckboxes(table); }); + } - $(document).on('change', '.email-checkbox, .web-checkbox', function () { - const column = $(this).hasClass("email-checkbox") ? "email" : "web"; - const mainCheckbox = $(this).closest("table").find(`.main-checkbox[data-column="${column}"]`); - const checkboxes = $(this).closest("table").find(`.${column}-checkbox`); - mainCheckbox.prop("checked", checkboxes.length === checkboxes.filter(':checked').length); - showToast('success', 'Settings updated successfully.'); + function updateIndividualSetting(userId, checkbox) { + const data = {}; + data[checkbox.data('type')] = checkbox.is(':checked'); + $.ajax({ + type: 'PATCH', + url: '/api/v1/notifications/user/' + userId + '/user-setting/' + checkbox.data('pk') + '/', + headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, + contentType: "application/json", + data: JSON.stringify(data), + success: function () { + showToast('success', 'Settings updated successfully.'); + }, + error: function () { + showToast('error', 'Something went wrong. Please try again.'); + } + }); + } + + function updateOrganizationSetting(userId, checkbox) { + const organizationId = checkbox.data('organization-id'); + const data = { + email: checkbox.closest('tr').find('.main-checkbox[data-column="email"]').is(':checked'), + web: checkbox.closest('tr').find('.main-checkbox[data-column="web"]').is(':checked') + }; + $.ajax({ + type: 'POST', + url: '/api/v1/notifications/user/' + userId + '/organization/' + organizationId + '/setting/', + headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, + contentType: "application/json", + data: JSON.stringify(data), + success: function () { + showToast('success', 'Organization settings updated successfully.'); + }, + error: function () { + showToast('error', 'Something went wrong. Please try again.'); + } }); + } + + function updateOrgLevelCheckboxes(organizationId) { + const emailCheckboxes = $('.email-checkbox[data-organization-id="' + organizationId + '"]'); + const webCheckboxes = $('.web-checkbox[data-organization-id="' + organizationId + '"]'); + const emailMainCheckbox = $('.main-checkbox[data-column="email"][data-organization-id="' + organizationId + '"]'); + const webMainCheckbox = $('.main-checkbox[data-column="web"][data-organization-id="' + organizationId + '"]'); + emailMainCheckbox.prop('checked', emailCheckboxes.length === emailCheckboxes.filter(':checked').length); + webMainCheckbox.prop('checked', webCheckboxes.length === webCheckboxes.filter(':checked').length); + } - $("#global-email, #global-web").change(function () { - const isEmail = $(this).attr("id") === "global-email"; - const columnClass = isEmail ? "email-checkbox" : "web-checkbox"; - $(`.${columnClass}`).prop("checked", $(this).prop("checked")); - $(`.main-checkbox[data-column="${isEmail ? "email" : "web"}"]`).prop("checked", $(this).prop("checked")); - showToast('success', 'Global settings updated successfully.'); + function initializeGlobalSettingsEventListener(userId) { + $('#global-email, #global-web').change(function () { + const isGlobalEmailChecked = $('#global-email').is(':checked'); + const isGlobalWebChecked = $('#global-web').is(':checked'); + const data = { email: isGlobalEmailChecked, web: isGlobalWebChecked }; + + isGlobalChange = true; + $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); + $('.main-checkbox[data-column="web"]').prop('checked', isGlobalWebChecked).change(); + $('.email-checkbox').prop('checked', isGlobalEmailChecked); + $('.web-checkbox').prop('checked', isGlobalWebChecked); + + $.ajax({ + type: 'POST', + url: '/api/v1/notifications/user/' + userId + '/preference/', + headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, + contentType: "application/json", + data: JSON.stringify(data), + success: function () { + showToast('success', 'Global settings updated successfully.'); + }, + error: function () { + showToast('error', 'Something went wrong. Please try again.'); + }, + complete: function() { + isGlobalChange = false; + } + }); }); } function showToast(level, message) { - const toast = $(` -
-
-
-
- ${message} -
-
- `); + const toast = $( + '
' + + '
' + + '
' + + '
' + + message + + '
' + + '
' + ); $('.ow-notification-toast-wrapper').prepend(toast); toast.slideDown('slow', function () { setTimeout(function () { @@ -152,5 +204,4 @@ toast.remove(); }); } - })(django.jQuery); From 0e5ebd2c01f5b231a1f22675be71322711986c4a Mon Sep 17 00:00:00 2001 From: Dhanus Date: Mon, 5 Aug 2024 14:41:52 +0530 Subject: [PATCH 13/74] [chore] CSS updates --- .../openwisp-notifications/css/settings.css | 15 ++++----------- .../static/openwisp-notifications/js/settings.js | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/openwisp_notifications/static/openwisp-notifications/css/settings.css b/openwisp_notifications/static/openwisp-notifications/css/settings.css index 90933110..30f92207 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/settings.css +++ b/openwisp_notifications/static/openwisp-notifications/css/settings.css @@ -1,17 +1,10 @@ -.container { +.settings-container { max-width: 1200px; margin: 0 auto; padding: 20px; } -h2 { - margin-bottom: 10px; -} .global-settings { - margin-bottom: 20px; -} -.org-panel { - background-color: #fff; - border: 1px solid #ddd; + margin: 10px 0 20px; } .org-header { background-color: #e0e0e0; @@ -51,8 +44,8 @@ td:not(:first-child) { text-align: center; } .checkbox { - width: 15px; - height: 15px; + width: 17px; + height: 17px; text-align: center; } .no-settings { diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index d19af8b3..6894d7ff 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -4,7 +4,7 @@ let isGlobalChange = false; $(document).ready(function () { - const userId = $('.container').data('user-id'); + const userId = $('.settings-container').data('user-id'); fetchNotificationSettings(userId); initializeGlobalSettingsEventListener(userId); }); From 44ca0dc3f26c64b811b360577da88f3ca769a83d Mon Sep 17 00:00:00 2001 From: Dhanus Date: Tue, 6 Aug 2024 08:36:07 +0530 Subject: [PATCH 14/74] [chore] Bump changes --- openwisp_notifications/base/views.py | 8 ++-- .../openwisp-notifications/js/settings.js | 46 ++++++++++--------- .../openwisp_notifications/settings.html | 2 +- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/openwisp_notifications/base/views.py b/openwisp_notifications/base/views.py index a7bf7ef8..cfaaa104 100644 --- a/openwisp_notifications/base/views.py +++ b/openwisp_notifications/base/views.py @@ -19,9 +19,9 @@ def get_context_data(self, **kwargs): user = User.objects.get(pk=user_id) # Only admin should access other users settings if not self.request.user.is_staff: - raise Http404("You do not have permission to access this page.") + raise Http404('You do not have permission to access this page.') except User.DoesNotExist: - raise Http404("User does not exist") + raise Http404('User does not exist') else: user = self.request.user @@ -29,9 +29,9 @@ def get_context_data(self, **kwargs): return context def test_func(self): - """ + ''' This method ensures that only admins can access the view when a custom user ID is provided. - """ + ''' if 'pk' in self.kwargs: return self.request.user.is_staff return True diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index 6894d7ff..4c2d0f74 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -1,5 +1,9 @@ 'use strict'; +if (typeof gettext === 'undefined') { + var gettext = function(word){ return word; }; +} + (function ($) { let isGlobalChange = false; @@ -15,7 +19,7 @@ renderNotificationSettings(groupedData); initializeEventListeners(userId); }).fail(function () { - showToast('error', 'Error fetching notification settings. Please try again.'); + showToast('error', gettext('Error fetching notification settings. Please try again.')); }); } @@ -27,7 +31,7 @@ } function renderNotificationSettings(data) { - const orgPanelsContainer = $("#org-panels").empty(); + const orgPanelsContainer = $('#org-panels').empty(); Object.keys(data).sort().forEach(function(orgName) { const orgSettings = data[orgName].sort(function(a, b) { return a.type.localeCompare(b.type); @@ -38,14 +42,14 @@ '
' + '
' ); - const orgContent = orgPanel.find(".org-content"); + const orgContent = orgPanel.find('.org-content'); if (orgSettings.length > 0) { const table = $( '' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '' + '
Settings Email Web' + gettext('Settings') + ' ' + gettext('Email') + ' ' + gettext('Web') + '
' ); @@ -53,8 +57,8 @@ const row = $( '' + '' + setting.type + '' + - '' + - '' + + '' + + '' + '' ); table.append(row); @@ -62,7 +66,7 @@ orgContent.append(table); updateMainCheckboxes(table); } else { - orgContent.append('
No settings available for this organization
'); + orgContent.append('
' + gettext('No settings available for this organization') + '
'); } orgPanelsContainer.append(orgPanel); }); @@ -78,9 +82,9 @@ function initializeEventListeners(userId) { $(document).on('click', '.org-header', function () { - const toggle = $(this).find(".toggle"); - toggle.text(toggle.text() === "▼" ? "▲" : "▼"); - $(this).next(".org-content").toggleClass("active"); + const toggle = $(this).find('.toggle'); + toggle.text(toggle.text() === '▼' ? '▲' : '▼'); + $(this).next('.org-content').toggleClass('active'); }); $(document).on('change', '.email-checkbox, .web-checkbox', function () { @@ -109,13 +113,13 @@ type: 'PATCH', url: '/api/v1/notifications/user/' + userId + '/user-setting/' + checkbox.data('pk') + '/', headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, - contentType: "application/json", + contentType: 'application/json', data: JSON.stringify(data), success: function () { - showToast('success', 'Settings updated successfully.'); + showToast('success', gettext('Settings updated successfully.')); }, error: function () { - showToast('error', 'Something went wrong. Please try again.'); + showToast('error', gettext('Something went wrong. Please try again.')); } }); } @@ -130,13 +134,13 @@ type: 'POST', url: '/api/v1/notifications/user/' + userId + '/organization/' + organizationId + '/setting/', headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, - contentType: "application/json", + contentType: 'application/json', data: JSON.stringify(data), success: function () { - showToast('success', 'Organization settings updated successfully.'); + showToast('success', gettext('Organization settings updated successfully.')); }, error: function () { - showToast('error', 'Something went wrong. Please try again.'); + showToast('error', gettext('Something went wrong. Please try again.')); } }); } @@ -166,13 +170,13 @@ type: 'POST', url: '/api/v1/notifications/user/' + userId + '/preference/', headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, - contentType: "application/json", + contentType: 'application/json', data: JSON.stringify(data), success: function () { - showToast('success', 'Global settings updated successfully.'); + showToast('success', gettext('Global settings updated successfully.')); }, error: function () { - showToast('error', 'Something went wrong. Please try again.'); + showToast('error', gettext('Something went wrong. Please try again.')); }, complete: function() { isGlobalChange = false; diff --git a/openwisp_notifications/templates/openwisp_notifications/settings.html b/openwisp_notifications/templates/openwisp_notifications/settings.html index 466201d8..0f816b35 100644 --- a/openwisp_notifications/templates/openwisp_notifications/settings.html +++ b/openwisp_notifications/templates/openwisp_notifications/settings.html @@ -27,7 +27,7 @@ {% endblock breadcrumbs %} {% block content %} -
+

Notification Preferences

Global Settings: From 85bb0c9d93edf78efe54cfacf77907008b40664e Mon Sep 17 00:00:00 2001 From: Dhanus Date: Tue, 6 Aug 2024 22:39:50 +0530 Subject: [PATCH 15/74] [fix] Verbose errora admin user page --- openwisp_notifications/base/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openwisp_notifications/base/admin.py b/openwisp_notifications/base/admin.py index 003a814f..fda23a24 100644 --- a/openwisp_notifications/base/admin.py +++ b/openwisp_notifications/base/admin.py @@ -26,6 +26,7 @@ def get_queryset(self, request): super() .get_queryset(request) .filter(deleted=False) + .exclude(organization=None) .prefetch_related('organization') ) From b2643242e0a08789c1fdf8573e631cd88267c6a1 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Tue, 6 Aug 2024 22:40:01 +0530 Subject: [PATCH 16/74] [chore] Web notification on the left, email on the right --- .../openwisp-notifications/js/settings.js | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index 4c2d0f74..7146558e 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -48,8 +48,8 @@ if (typeof gettext === 'undefined') { '' + '' + '' + - '' + '' + + '' + '' + '
' + gettext('Settings') + ' ' + gettext('Email') + ' ' + gettext('Web') + ' ' + gettext('Email') + '
' ); @@ -57,8 +57,8 @@ if (typeof gettext === 'undefined') { const row = $( '' + '' + setting.type + '' + - '' + '' + + '' + '' ); table.append(row); @@ -127,8 +127,8 @@ if (typeof gettext === 'undefined') { function updateOrganizationSetting(userId, checkbox) { const organizationId = checkbox.data('organization-id'); const data = { - email: checkbox.closest('tr').find('.main-checkbox[data-column="email"]').is(':checked'), - web: checkbox.closest('tr').find('.main-checkbox[data-column="web"]').is(':checked') + web: checkbox.closest('tr').find('.main-checkbox[data-column="web"]').is(':checked'), + email: checkbox.closest('tr').find('.main-checkbox[data-column="email"]').is(':checked') }; $.ajax({ type: 'POST', @@ -146,25 +146,25 @@ if (typeof gettext === 'undefined') { } function updateOrgLevelCheckboxes(organizationId) { - const emailCheckboxes = $('.email-checkbox[data-organization-id="' + organizationId + '"]'); const webCheckboxes = $('.web-checkbox[data-organization-id="' + organizationId + '"]'); - const emailMainCheckbox = $('.main-checkbox[data-column="email"][data-organization-id="' + organizationId + '"]'); + const emailCheckboxes = $('.email-checkbox[data-organization-id="' + organizationId + '"]'); const webMainCheckbox = $('.main-checkbox[data-column="web"][data-organization-id="' + organizationId + '"]'); - emailMainCheckbox.prop('checked', emailCheckboxes.length === emailCheckboxes.filter(':checked').length); + const emailMainCheckbox = $('.main-checkbox[data-column="email"][data-organization-id="' + organizationId + '"]'); webMainCheckbox.prop('checked', webCheckboxes.length === webCheckboxes.filter(':checked').length); + emailMainCheckbox.prop('checked', emailCheckboxes.length === emailCheckboxes.filter(':checked').length); } function initializeGlobalSettingsEventListener(userId) { $('#global-email, #global-web').change(function () { - const isGlobalEmailChecked = $('#global-email').is(':checked'); const isGlobalWebChecked = $('#global-web').is(':checked'); - const data = { email: isGlobalEmailChecked, web: isGlobalWebChecked }; + const isGlobalEmailChecked = $('#global-email').is(':checked'); + const data = { web: isGlobalWebChecked, email: isGlobalEmailChecked }; isGlobalChange = true; - $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); $('.main-checkbox[data-column="web"]').prop('checked', isGlobalWebChecked).change(); - $('.email-checkbox').prop('checked', isGlobalEmailChecked); + $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); $('.web-checkbox').prop('checked', isGlobalWebChecked); + $('.email-checkbox').prop('checked', isGlobalEmailChecked); $.ajax({ type: 'POST', From b175fa15afd189e10c460ec9f06001fcfa556a57 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sat, 10 Aug 2024 09:51:35 +0530 Subject: [PATCH 17/74] [chore] Handling auto trigger of email without web checkboxes --- .../openwisp-notifications/js/settings.js | 91 ++++++++++++++++--- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index 7146558e..c21234fb 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -91,27 +91,74 @@ if (typeof gettext === 'undefined') { if (isGlobalChange) { return; } - updateIndividualSetting(userId, $(this)); - updateOrgLevelCheckboxes($(this).data('organization-id')); + + const organizationId = $(this).data('organization-id'); + const settingId = $(this).data('pk'); + const triggeredBy = $(this).data('type'); + + let isWebChecked = $(`.web-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).is(':checked'); + let isEmailChecked = $(`.email-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).is(':checked'); + + if (triggeredBy === 'email' && isEmailChecked) { + isWebChecked = true; + } + + if (triggeredBy === 'web' && !isWebChecked) { + isEmailChecked = false; + } + + $(`.web-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).prop('checked', isWebChecked); + $(`.email-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).prop('checked', isEmailChecked); + + updateIndividualSetting(settingId, isWebChecked, isEmailChecked); + updateOrgLevelCheckboxes(organizationId); }); $(document).on('change', '.main-checkbox', function () { if (isGlobalChange) { return; } - updateOrganizationSetting(userId, $(this)); + const orgId = $(this).data('organization-id'); + const triggeredBy = $(this).data('column'); + + let isOrgWebChecked = $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).is(':checked'); + let isOrgEmailChecked = $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).is(':checked'); + + // Ensure web is checked if email is checked + if (triggeredBy === 'email' && isOrgEmailChecked) { + isOrgWebChecked = true; + } + + // Ensure email is unchecked if web is unchecked + if (triggeredBy === 'web' && !isOrgWebChecked) { + isOrgEmailChecked = false; + } + + $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).prop('checked', isOrgWebChecked); + $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).prop('checked', isOrgEmailChecked); + + isGlobalChange = true; + const table = $(this).closest('table'); - table.find('.' + $(this).data('column') + '-checkbox').prop('checked', $(this).is(':checked')); + table.find('.web-checkbox').prop('checked', isOrgWebChecked).change(); + table.find('.email-checkbox').prop('checked', isOrgEmailChecked).change(); + updateMainCheckboxes(table); + + updateOrganizationSetting(userId, $(this)); + isGlobalChange = false; }); } - function updateIndividualSetting(userId, checkbox) { - const data = {}; - data[checkbox.data('type')] = checkbox.is(':checked'); + function updateIndividualSetting(settingId, isWebChecked, isEmailChecked) { + const userId = $('.settings-container').data('user-id'); + const data = { + web: isWebChecked, + email: isEmailChecked + }; $.ajax({ type: 'PATCH', - url: '/api/v1/notifications/user/' + userId + '/user-setting/' + checkbox.data('pk') + '/', + url: '/api/v1/notifications/user/' + userId + '/user-setting/' + settingId + '/', headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, contentType: 'application/json', data: JSON.stringify(data), @@ -155,12 +202,30 @@ if (typeof gettext === 'undefined') { } function initializeGlobalSettingsEventListener(userId) { - $('#global-email, #global-web').change(function () { - const isGlobalWebChecked = $('#global-web').is(':checked'); - const isGlobalEmailChecked = $('#global-email').is(':checked'); - const data = { web: isGlobalWebChecked, email: isGlobalEmailChecked }; + $('#global-email, #global-web').change(function (event) { + const triggeredBy = $(event.target).attr('id'); + + let isGlobalWebChecked = $('#global-web').is(':checked'); + let isGlobalEmailChecked = $('#global-email').is(':checked'); + + if (triggeredBy === 'global-email' && isGlobalEmailChecked) { + isGlobalWebChecked = true; + } + + if (triggeredBy === 'global-web' && !isGlobalWebChecked) { + isGlobalEmailChecked = false; + } + + $('#global-web').prop('checked', isGlobalWebChecked); + $('#global-email').prop('checked', isGlobalEmailChecked); + + const data = { + web: isGlobalWebChecked, + email: isGlobalEmailChecked + }; isGlobalChange = true; + $('.main-checkbox[data-column="web"]').prop('checked', isGlobalWebChecked).change(); $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); $('.web-checkbox').prop('checked', isGlobalWebChecked); @@ -178,7 +243,7 @@ if (typeof gettext === 'undefined') { error: function () { showToast('error', gettext('Something went wrong. Please try again.')); }, - complete: function() { + complete: function () { isGlobalChange = false; } }); From c0d373fa8246e81c2f5c2c162a06590fa0e297ae Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sat, 10 Aug 2024 10:15:08 +0530 Subject: [PATCH 18/74] [chore] Automatically open first org dropdown, label for org level email and web checkboxes --- .../static/openwisp-notifications/js/settings.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index c21234fb..6c1d3529 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -32,7 +32,7 @@ if (typeof gettext === 'undefined') { function renderNotificationSettings(data) { const orgPanelsContainer = $('#org-panels').empty(); - Object.keys(data).sort().forEach(function(orgName) { + Object.keys(data).sort().forEach(function(orgName, index) { const orgSettings = data[orgName].sort(function(a, b) { return a.type.localeCompare(b.type); }); @@ -48,8 +48,8 @@ if (typeof gettext === 'undefined') { '' + '' + '' + - '' + - '' + + '' + + '' + '' + '
' + gettext('Settings') + ' ' + gettext('Web') + ' ' + gettext('Email') + '
' ); @@ -69,6 +69,12 @@ if (typeof gettext === 'undefined') { orgContent.append('
' + gettext('No settings available for this organization') + '
'); } orgPanelsContainer.append(orgPanel); + + // Automatically open the first organization panel + if (index === 0) { + orgContent.addClass('active'); + orgPanel.find('.toggle').text('▲'); + } }); } From 487f6dd732f048f194a9ebfdf671f74c0cf5a836 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sat, 10 Aug 2024 13:28:39 +0530 Subject: [PATCH 19/74] [chore] Fetch current user global setting preference and make it default --- openwisp_notifications/api/views.py | 10 +++++ .../openwisp-notifications/js/settings.js | 40 +++++++++++++------ .../openwisp_notifications/settings.html | 8 ++-- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index 2f218b14..1a52c6fe 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -231,6 +231,16 @@ class NotificationPreferenceView(GenericAPIView): permission_classes = [IsAuthenticated, IsAuthenticatedToUpdateNotificationSetting] serializer_class = NotificationSettingUpdateSerializer + def get(self, request, user_id): + notification_settings, created = NotificationSetting.objects.get_or_create( + user_id=user_id, + organization=None, + type=None, + defaults={'email': True, 'web': True} + ) + serializer = self.get_serializer(notification_settings) + return Response(serializer.data, status=status.HTTP_200_OK) + def post(self, request, user_id): serializer = self.get_serializer(data=request.data) if serializer.is_valid(): diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index 6c1d3529..6445a59f 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -9,14 +9,29 @@ if (typeof gettext === 'undefined') { $(document).ready(function () { const userId = $('.settings-container').data('user-id'); - fetchNotificationSettings(userId); - initializeGlobalSettingsEventListener(userId); + fetchGlobalSettings(userId); }); - function fetchNotificationSettings(userId) { + function fetchGlobalSettings(userId) { + $.getJSON(`/api/v1/notifications/user/${userId}/preference/`, function (globalData) { + const isGlobalWebChecked = globalData.web; + const isGlobalEmailChecked = globalData.email; + + $('#global-web').prop('checked', isGlobalWebChecked); + $('#global-email').prop('checked', isGlobalEmailChecked); + + initializeGlobalSettingsEventListener(userId); + + fetchNotificationSettings(userId, isGlobalWebChecked, isGlobalEmailChecked); + }).fail(function () { + showToast('error', gettext('Error fetching global settings. Please try again.')); + }); + } + + function fetchNotificationSettings(userId, isGlobalWebChecked, isGlobalEmailChecked) { $.getJSON(`/api/v1/notifications/user/${userId}/user-setting/`, function (data) { const groupedData = groupBy(data.results, 'organization_name'); - renderNotificationSettings(groupedData); + renderNotificationSettings(groupedData, isGlobalWebChecked, isGlobalEmailChecked); initializeEventListeners(userId); }).fail(function () { showToast('error', gettext('Error fetching notification settings. Please try again.')); @@ -30,7 +45,7 @@ if (typeof gettext === 'undefined') { }, {}); } - function renderNotificationSettings(data) { + function renderNotificationSettings(data, isGlobalWebChecked, isGlobalEmailChecked) { const orgPanelsContainer = $('#org-panels').empty(); Object.keys(data).sort().forEach(function(orgName, index) { const orgSettings = data[orgName].sort(function(a, b) { @@ -48,8 +63,8 @@ if (typeof gettext === 'undefined') { '' + '' + '' + - '' + - '' + + '' + + '' + '' + '
' + gettext('Settings') + '
' ); @@ -200,7 +215,7 @@ if (typeof gettext === 'undefined') { function updateOrgLevelCheckboxes(organizationId) { const webCheckboxes = $('.web-checkbox[data-organization-id="' + organizationId + '"]'); - const emailCheckboxes = $('.email-checkbox[data-organization-id="' + organizationId + '"]'); + const emailCheckboxes = $('..email-checkbox[data-organization-id="' + organizationId + '"]'); const webMainCheckbox = $('.main-checkbox[data-column="web"][data-organization-id="' + organizationId + '"]'); const emailMainCheckbox = $('.main-checkbox[data-column="email"][data-organization-id="' + organizationId + '"]'); webMainCheckbox.prop('checked', webCheckboxes.length === webCheckboxes.filter(':checked').length); @@ -232,11 +247,6 @@ if (typeof gettext === 'undefined') { isGlobalChange = true; - $('.main-checkbox[data-column="web"]').prop('checked', isGlobalWebChecked).change(); - $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); - $('.web-checkbox').prop('checked', isGlobalWebChecked); - $('.email-checkbox').prop('checked', isGlobalEmailChecked); - $.ajax({ type: 'POST', url: '/api/v1/notifications/user/' + userId + '/preference/', @@ -245,6 +255,10 @@ if (typeof gettext === 'undefined') { data: JSON.stringify(data), success: function () { showToast('success', gettext('Global settings updated successfully.')); + $('.main-checkbox[data-column="web"]').prop('checked', isGlobalWebChecked).change(); + $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); + $('.web-checkbox').prop('checked', isGlobalWebChecked); + $('.email-checkbox').prop('checked', isGlobalEmailChecked); }, error: function () { showToast('error', gettext('Something went wrong. Please try again.')); diff --git a/openwisp_notifications/templates/openwisp_notifications/settings.html b/openwisp_notifications/templates/openwisp_notifications/settings.html index 0f816b35..a65633ec 100644 --- a/openwisp_notifications/templates/openwisp_notifications/settings.html +++ b/openwisp_notifications/templates/openwisp_notifications/settings.html @@ -32,12 +32,12 @@

Notification Preferences

Global Settings:
From a5d8b4ec218c73fd7ccb320b5c5498d612d55972 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sat, 10 Aug 2024 14:03:14 +0530 Subject: [PATCH 20/74] [chore] Update type_label for user-setting api --- openwisp_notifications/api/serializers.py | 4 ++++ .../static/openwisp-notifications/js/settings.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/openwisp_notifications/api/serializers.py b/openwisp_notifications/api/serializers.py index 9baf8eff..a64ff3eb 100644 --- a/openwisp_notifications/api/serializers.py +++ b/openwisp_notifications/api/serializers.py @@ -74,10 +74,14 @@ class Meta(NotificationSerializer.Meta): class NotificationSettingSerializer(serializers.ModelSerializer): organization_name = serializers.SerializerMethodField() + type_label = serializers.SerializerMethodField() def get_organization_name(self, obj): return obj.organization.name if obj.organization else None + def get_type_label(self, obj): + return obj.type_config['verbose_name'] + class Meta: model = NotificationSetting exclude = ['user'] diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index 6445a59f..a4766b95 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -71,7 +71,7 @@ if (typeof gettext === 'undefined') { orgSettings.forEach(function(setting) { const row = $( '' + - '' + setting.type + '' + + '' + setting.type_label + '' + '' + '' + '' From cb8a296c7e34a83f1467b2fe2ee60fba6d686e39 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Sun, 11 Aug 2024 18:44:06 +0530 Subject: [PATCH 21/74] [chore] Reduced CSS and replicate admin design --- .../openwisp-notifications/css/settings.css | 23 +------------------ .../openwisp-notifications/js/settings.js | 9 +++++--- .../openwisp_notifications/settings.html | 5 ++-- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/openwisp_notifications/static/openwisp-notifications/css/settings.css b/openwisp_notifications/static/openwisp-notifications/css/settings.css index 30f92207..8be297ad 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/settings.css +++ b/openwisp_notifications/static/openwisp-notifications/css/settings.css @@ -1,8 +1,3 @@ -.settings-container { - max-width: 1200px; - margin: 0 auto; - padding: 20px; -} .global-settings { margin: 10px 0 20px; } @@ -15,7 +10,7 @@ cursor: pointer; } .org-content { - padding: 10px; + padding-top: 10px; display: none; } .org-content.active { @@ -23,17 +18,6 @@ } table { width: 100%; - border-collapse: separate; - border-spacing: 0; -} -th, -td { - padding: 8px; - text-align: left; - border-bottom: 1px solid #ddd; -} -th { - background-color: #f2f2f2; } th:not(:last-child), td:not(:last-child) { @@ -43,11 +27,6 @@ th:not(:first-child), td:not(:first-child) { text-align: center; } -.checkbox { - width: 17px; - height: 17px; - text-align: center; -} .no-settings { padding: 10px; text-align: center; diff --git a/openwisp_notifications/static/openwisp-notifications/js/settings.js b/openwisp_notifications/static/openwisp-notifications/js/settings.js index a4766b95..62349de3 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/settings.js +++ b/openwisp_notifications/static/openwisp-notifications/js/settings.js @@ -49,7 +49,7 @@ if (typeof gettext === 'undefined') { const orgPanelsContainer = $('#org-panels').empty(); Object.keys(data).sort().forEach(function(orgName, index) { const orgSettings = data[orgName].sort(function(a, b) { - return a.type.localeCompare(b.type); + return a.type_label.localeCompare(b.type_label); }); const orgPanel = $( '
' + @@ -61,11 +61,14 @@ if (typeof gettext === 'undefined') { if (orgSettings.length > 0) { const table = $( '' + + '' + '' + '' + '' + '' + '' + + '' + + '' + '
' + gettext('Settings') + '
' ); orgSettings.forEach(function(setting) { @@ -76,7 +79,7 @@ if (typeof gettext === 'undefined') { '' + '' ); - table.append(row); + table.find('tbody').append(row); }); orgContent.append(table); updateMainCheckboxes(table); @@ -215,7 +218,7 @@ if (typeof gettext === 'undefined') { function updateOrgLevelCheckboxes(organizationId) { const webCheckboxes = $('.web-checkbox[data-organization-id="' + organizationId + '"]'); - const emailCheckboxes = $('..email-checkbox[data-organization-id="' + organizationId + '"]'); + const emailCheckboxes = $('.email-checkbox[data-organization-id="' + organizationId + '"]'); const webMainCheckbox = $('.main-checkbox[data-column="web"][data-organization-id="' + organizationId + '"]'); const emailMainCheckbox = $('.main-checkbox[data-column="email"][data-organization-id="' + organizationId + '"]'); webMainCheckbox.prop('checked', webCheckboxes.length === webCheckboxes.filter(':checked').length); diff --git a/openwisp_notifications/templates/openwisp_notifications/settings.html b/openwisp_notifications/templates/openwisp_notifications/settings.html index a65633ec..18caa9f0 100644 --- a/openwisp_notifications/templates/openwisp_notifications/settings.html +++ b/openwisp_notifications/templates/openwisp_notifications/settings.html @@ -4,7 +4,7 @@ {% load static %} {% block title %} - {% trans "Notification Settings" %} + {% trans "Notification Preferences" %} {% endblock %} {% block extrastyle %} @@ -26,9 +26,10 @@
{% endblock breadcrumbs %} +{% block pretitle %}Notification Preferences{% endblock %} + {% block content %}
-

Notification Preferences

Global Settings:
' + @@ -263,6 +254,7 @@ function getAbsoluteUrl(url) { if (isUpdateInProgress) { return; } + const table = $(this).closest('table'); const orgId = $(this).data('organization-id'); const triggeredBy = $(this).data('column'); @@ -271,6 +263,14 @@ function getAbsoluteUrl(url) { let previousOrgWebChecked, previousOrgEmailChecked; + const previousWebState = table.find('.web-checkbox').map(function() { + return { id: $(this).data('pk'), checked: $(this).is(':checked') }; + }).get(); + + const previousEmailState = table.find('.email-checkbox').map(function() { + return { id: $(this).data('pk'), checked: $(this).is(':checked') }; + }).get(); + if (triggeredBy === 'email') { previousOrgEmailChecked = !isOrgEmailChecked; previousOrgWebChecked = isOrgWebChecked; @@ -297,9 +297,10 @@ function getAbsoluteUrl(url) { $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).prop('checked', isOrgWebChecked); $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).prop('checked', isOrgEmailChecked); - const table = $(this).closest('table'); table.find('.web-checkbox').prop('checked', isOrgWebChecked).change(); - table.find('.email-checkbox').prop('checked', isOrgEmailChecked).change(); + if ((triggeredBy === 'web' && !isOrgWebChecked) || triggeredBy === 'email') { + table.find('.email-checkbox').prop('checked', isOrgEmailChecked).change(); + } updateMainCheckboxes(table); @@ -316,8 +317,12 @@ function getAbsoluteUrl(url) { showToast('error', gettext('Something went wrong. Please try again.')); $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).prop('checked', previousOrgWebChecked); $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).prop('checked', previousOrgEmailChecked); - table.find('.web-checkbox').prop('checked', previousOrgWebChecked); - table.find('.email-checkbox').prop('checked', previousOrgEmailChecked); + previousWebState.forEach(function(item) { + $(`.web-checkbox[data-pk="${item.id}"]`).prop('checked', item.checked); + }); + previousEmailState.forEach(function(item) { + $(`.email-checkbox[data-pk="${item.id}"]`).prop('checked', item.checked); + }); updateMainCheckboxes(table); }, complete: function () { @@ -390,13 +395,15 @@ function getAbsoluteUrl(url) { $('#global-email').prop('checked', isGlobalEmailChecked); $('.main-checkbox[data-column="web"]').prop('checked', isGlobalWebChecked).change(); - $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); $('.web-checkbox').prop('checked', isGlobalWebChecked); - $('.email-checkbox').prop('checked', isGlobalEmailChecked); + if ((triggeredBy === 'global-web' && !isGlobalWebChecked) || triggeredBy === 'global-email') { + $('.email-checkbox').prop('checked', isGlobalEmailChecked); + $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); + } $.ajax({ - type: 'POST', - url: getAbsoluteUrl(`/api/v1/notifications/user/${userId}/preference/`), + type: 'PATCH', + url: getAbsoluteUrl(`/api/v1/notifications/user/${userId}/user-setting/${globalSettingId}/`), headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, contentType: 'application/json', data: JSON.stringify(data), diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index 196455a8..45ce24ea 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -259,10 +259,10 @@ def test_bearer_authentication(self, mocked_test): self.client.logout() notify.send(sender=self.admin, type='default', target=self._get_org_user()) n = Notification.objects.first() - notification_setting = NotificationSetting.objects.first() - notification_setting_count = NotificationSetting.objects.exclude( - organization__isnull=True - ).count() + notification_setting = NotificationSetting.objects.exclude( + organization=None + ).first() + notification_setting_count = NotificationSetting.objects.count() token = self._obtain_auth_token(username='admin', password='tester') with self.subTest('Test listing all notifications'): @@ -501,9 +501,7 @@ def test_obsolete_notifications_busy_worker(self, mocked_task): def test_notification_setting_list_api(self): self._create_org_user(is_admin=True) - number_of_settings = NotificationSetting.objects.filter( - user=self.admin, organization__isnull=False - ).count() + number_of_settings = NotificationSetting.objects.filter(user=self.admin).count() url = self._get_path('notification_setting_list') with self.subTest('Test notification setting list view'): @@ -548,7 +546,7 @@ def test_notification_setting_list_api(self): next_response.data['next'], ) else: - self.assertIsNone(next_response.data['next']) + self.assertIsNotNone(next_response.data['next']) with self.subTest('Test individual result object'): response = self.client.get(url) @@ -564,9 +562,7 @@ def test_list_notification_setting_filtering(self): tester = self._create_user() with self.subTest('Test listing notification setting without filters'): - count = NotificationSetting.objects.exclude( - organization__isnull=True - ).count() + count = NotificationSetting.objects.count() response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data['results']), count) @@ -850,82 +846,6 @@ def test_organization_notification_setting_update(self): response = self.client.post(url, data={'web': 'invalid'}) self.assertEqual(response.status_code, 400) - def test_get_notification_preference(self): - tester = self._create_user() - - with self.subTest('Test for current user'): - self.client.force_login(self.admin) - url = self._get_path('notification_preference', self.admin.pk) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertIn('web', response.json()) - self.assertIn('email', response.json()) - self.assertTrue(response.json()['web']) - self.assertTrue(response.json()['email']) - - with self.subTest('Test for admin user accessing another user'): - self.client.force_login(self.admin) - url = self._get_path('notification_preference', tester.pk) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertIn('web', response.json()) - self.assertIn('email', response.json()) - self.assertTrue(response.json()['web']) - self.assertTrue(response.json()['email']) - - with self.subTest('Test for non-admin user accessing another user'): - self.client.force_login(tester) - url = self._get_path('notification_preference', self.admin.pk) - response = self.client.get(url) - self.assertEqual(response.status_code, 403) - - def test_post_notification_preference(self): - tester = self._create_user() - - with self.subTest('Test for current user'): - url = self._get_path( - 'notification_preference', - self.admin.pk, - ) - response = self.client.post(url, data={'web': True, 'email': True}) - self.assertEqual(response.status_code, 200) - notification_setting = NotificationSetting.objects.get( - user=self.admin, organization_id=None, type=None - ) - self.assertTrue(notification_setting.web, True) - self.assertTrue(notification_setting.email, True) - - with self.subTest('Test for admin user'): - url = self._get_path( - 'notification_preference', - tester.pk, - ) - response = self.client.post(url, data={'web': True, 'email': True}) - self.assertEqual(response.status_code, 200) - notification_setting = NotificationSetting.objects.get( - user=tester, organization_id=None, type=None - ) - self.assertTrue(notification_setting.web, True) - self.assertTrue(notification_setting.email, True) - - with self.subTest('Test for non-admin user'): - self.client.force_login(tester) - url = self._get_path( - 'notification_preference', - self.admin.pk, - ) - response = self.client.post(url, data={'web': True, 'email': True}) - self.assertEqual(response.status_code, 403) - - with self.subTest('Test with invalid data'): - self.client.force_login(self.admin) - url = self._get_path( - 'notification_preference', - self.admin.pk, - ) - response = self.client.post(url, data={'web': 'invalid'}) - self.assertEqual(response.status_code, 400) - @patch('openwisp_notifications.tasks.delete_ignore_object_notification.apply_async') def test_create_ignore_obj_notification_api(self, mocked_task): org_user = self._get_org_user() diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index 2f7c3430..3e6d1b92 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.core import mail from django.core.cache import cache -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db.models.signals import post_migrate, post_save from django.template import TemplateDoesNotExist from django.test import TransactionTestCase @@ -950,6 +950,24 @@ def test_notification_for_unverified_email(self): # we don't send emails to unverified email addresses self.assertEqual(len(mail.outbox), 0) + def test_validate_global_notification_setting(self): + with self.subTest('Test global notification setting creation'): + NotificationSetting.objects.filter( + user=self.admin, organization=None, type=None + ).delete() + global_setting = NotificationSetting( + user=self.admin, organization=None, type=None, email=True, web=True + ) + global_setting.save() + self.assertIsNotNone(global_setting) + + with self.subTest('Test only one global notification setting per user'): + global_setting = NotificationSetting( + user=self.admin, organization=None, type=None, email=True, web=True + ) + with self.assertRaises(ValidationError): + global_setting.save() + @patch('openwisp_notifications.tasks.send_batched_email_notifications.apply_async') def test_batch_email_notification(self, mock_send_email): fixed_datetime = datetime(2024, 7, 26, 11, 40) From 5413859ece828ba904488885021ea8d1755b48f1 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Fri, 13 Sep 2024 23:16:49 +0530 Subject: [PATCH 69/74] [fix] Import error --- openwisp_notifications/base/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index b99e9704..1b441a3b 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -6,8 +6,9 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site from django.core.cache import cache +from django.core.exceptions import ValidationError from django.db import models -from django.db.models.constraints import UniqueConstraint, ValidationError +from django.db.models.constraints import UniqueConstraint from django.template.loader import render_to_string from django.urls import reverse from django.utils.functional import cached_property From a796372fac040afdda7b314f4d491f6eb6167e71 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Tue, 17 Sep 2024 16:36:41 +0530 Subject: [PATCH 70/74] [chore] Bump changes --- openwisp_notifications/base/models.py | 39 +++--- ...ificationsetting_organization_and_more.py} | 16 ++- .../css/preferences.css | 9 ++ .../openwisp-notifications/js/preferences.js | 116 +++++++++++------- openwisp_notifications/tests/test_admin.py | 47 ++++--- openwisp_notifications/tests/test_api.py | 95 ++++++++------ .../tests/test_notification_setting.py | 73 ++++++++++- .../tests/test_notifications.py | 20 +-- openwisp_notifications/tests/test_selenium.py | 112 ++++++++++++++--- openwisp_notifications/tests/test_utils.py | 9 ++ openwisp_notifications/tests/test_widget.py | 95 -------------- openwisp_notifications/views.py | 3 +- ...ificationsetting_organization_and_more.py} | 17 ++- 13 files changed, 389 insertions(+), 262 deletions(-) rename openwisp_notifications/migrations/{0008_alter_notificationsetting_organization.py => 0008_alter_notificationsetting_organization_and_more.py} (56%) delete mode 100644 openwisp_notifications/tests/test_widget.py rename tests/openwisp2/sample_notifications/migrations/{0003_alter_notificationsetting_organization.py => 0003_alter_notificationsetting_organization_and_more.py} (52%) diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index 1b441a3b..43417a17 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -7,7 +7,7 @@ from django.contrib.sites.models import Site from django.core.cache import cache from django.core.exceptions import ValidationError -from django.db import models +from django.db import models, transaction from django.db.models.constraints import UniqueConstraint from django.template.loader import render_to_string from django.urls import reverse @@ -247,6 +247,7 @@ class AbstractNotificationSetting(UUIDModel): type = models.CharField( max_length=30, null=True, + blank=True, choices=NOTIFICATION_CHOICES, verbose_name='Notification Type', ) @@ -303,29 +304,33 @@ def validate_global_setting(self): raise ValidationError("There can only be one global setting per user.") def save(self, *args, **kwargs): - self.validate_global_setting() if not self.web_notification: self.email = self.web_notification - if not self.organization and not self.type: - try: - original = self.__class__.objects.get(pk=self.pk) - if self.web and (self.email == original.email): - self.user.notificationsetting_set.exclude(pk=self.pk).update( - web=self.web - ) - else: + with transaction.atomic(): + if not self.organization and not self.type: + try: + original = self.__class__.objects.get(pk=self.pk) + updates = {'web': self.web} + + # Update 'email' only if it's different from the previous state + if not self.web or self.email != original.email: + updates['email'] = self.email + self.user.notificationsetting_set.exclude(pk=self.pk).update( - web=self.web, email=self.email + **updates ) - except self.__class__.DoesNotExist: - pass + except self.__class__.DoesNotExist: + # Handle case when the object is being created + pass return super().save(*args, **kwargs) def full_clean(self, *args, **kwargs): - if self.email == self.type_config['email_notification']: - self.email = None - if self.web == self.type_config['web_notification']: - self.web = None + self.validate_global_setting() + if self.organization and self.type: + if self.email == self.type_config['email_notification']: + self.email = None + if self.web == self.type_config['web_notification']: + self.web = None return super().full_clean(*args, **kwargs) @property diff --git a/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization_and_more.py similarity index 56% rename from openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py rename to openwisp_notifications/migrations/0008_alter_notificationsetting_organization_and_more.py index 45930143..95a1e4ee 100644 --- a/openwisp_notifications/migrations/0008_alter_notificationsetting_organization.py +++ b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-06-18 13:05 +# Generated by Django 4.2.16 on 2024-09-17 13:19 import django.db.models.deletion from django.db import migrations, models @@ -21,4 +21,18 @@ class Migration(migrations.Migration): to="openwisp_users.organization", ), ), + migrations.AlterField( + model_name="notificationsetting", + name="type", + field=models.CharField( + blank=True, + choices=[ + ("default", "Default Type"), + ("generic_message", "Generic Message Type"), + ], + max_length=30, + null=True, + verbose_name="Notification Type", + ), + ), ] diff --git a/openwisp_notifications/static/openwisp-notifications/css/preferences.css b/openwisp_notifications/static/openwisp-notifications/css/preferences.css index 0319c10b..982cda1e 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/preferences.css +++ b/openwisp_notifications/static/openwisp-notifications/css/preferences.css @@ -157,6 +157,15 @@ input:checked + .slider:before { -ms-transform: translateX(20px); transform: translateX(20px); } +.notification-web-header, .notification-email-header { + text-align: center; +} +.notification-header-container { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; +} .slider.round { border-radius: 20px; } diff --git a/openwisp_notifications/static/openwisp-notifications/js/preferences.js b/openwisp_notifications/static/openwisp-notifications/js/preferences.js index 182232a5..af538bec 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/preferences.js +++ b/openwisp_notifications/static/openwisp-notifications/js/preferences.js @@ -11,7 +11,6 @@ function getAbsoluteUrl(url) { (function ($) { let isUpdateInProgress = false; let globalSettingId = null; - $('#ow-notifications-loader').removeClass('ow-hide'); $(document).ready(function () { const userId = $('.settings-container').data('user-id'); @@ -20,11 +19,10 @@ function getAbsoluteUrl(url) { function fetchNotificationSettings(userId) { let allResults = []; - let currentPage = 1; - (function fetchPage() { + function fetchPage(url) { $.ajax({ - url: getAbsoluteUrl(`/api/v1/notifications/user/${userId}/user-setting/?page_size=100&page=${currentPage}`), + url: url, dataType: 'json', beforeSend: function () { $('.loader').show(); @@ -37,20 +35,24 @@ function getAbsoluteUrl(url) { allResults = allResults.concat(data.results); if (data.next) { - currentPage++; // Continue fetching next page - fetchPage(); + fetchPage(data.next); } else { processNotificationSettings(allResults, userId); } }, error: function () { + $('#org-panels').append('
' + gettext('Error fetching notification settings. Please try again.') + '
'); showToast('error', gettext('Error fetching notification settings. Please try again.')); } }); - })(); + } + + const initialUrl = getAbsoluteUrl(`/api/v1/notifications/user/${userId}/user-setting/?page_size=100`); + fetchPage(initialUrl); } + // Process the fetched notification settings function processNotificationSettings(allResults, userId) { const globalSetting = allResults.find(setting => setting.organization === null && setting.type === null); const filteredResults = allResults.filter(setting => !(setting.organization === null && setting.type === null)); @@ -64,9 +66,12 @@ function getAbsoluteUrl(url) { $('#global-email').prop('checked', isGlobalEmailChecked); initializeGlobalSettingsEventListener(userId); + } else { + showToast('error', gettext('Global settings not found.')); } - const groupedData = groupBy(filteredResults, 'organization_name'); + // Group and render settings by organization_id + const groupedData = groupBy(filteredResults, 'organization'); renderNotificationSettings(groupedData); initializeEventListeners(userId); @@ -88,16 +93,21 @@ function getAbsoluteUrl(url) { return; } - Object.keys(data).sort().forEach(function(orgName, orgIndex) { - const orgSettings = data[orgName].sort(function(a, b) { + // Render settings for each organization + Object.keys(data).sort().forEach(function(orgId, orgIndex) { + const orgSettings = data[orgId].sort(function(a, b) { return a.type_label.localeCompare(b.type_label); }); + + const orgName = orgSettings[0].organization_name; + const orgPanel = $( '
' + - '

Organization: ' + orgName + '

' + + '

' + `${gettext('Organization')}: ${orgName}` + '

' + '
' + '
' ); + const orgContent = orgPanel.find('.org-content'); if (orgSettings.length > 0) { const table = $( @@ -105,44 +115,46 @@ function getAbsoluteUrl(url) { '' + '' + '' + gettext('Notification Type') + '' + - '' + - '
' + - '' + gettext('Web') + '' + - '?' + - '' + - '
' + + '' + + '
' + + '' + gettext('Web') + '' + + '?' + + '' + + '
' + '' + - '' + - '
' + - '' + gettext('Email') + '' + - '?' + - '' + - '
' + + '' + + '
' + + '' + gettext('Email') + '' + + '?' + + '' + + '
' + '' + '' + '' + '' + '' ); + + // Populate table rows with individual settings orgSettings.forEach(function(setting, settingIndex) { const row = $( '' + '' + setting.type_label + '' + '' + '' + '' + '' + '' + '' + @@ -150,13 +162,16 @@ function getAbsoluteUrl(url) { ); table.find('tbody').append(row); }); + orgContent.append(table); updateMainCheckboxes(table); } else { orgContent.append('
' + gettext('No settings available for this organization') + '
'); } + orgPanelsContainer.append(orgPanel); + // Expand the first organization by default if (orgIndex === 0) { orgContent.addClass('active'); orgPanel.find('.toggle-icon').removeClass('collapsed').addClass('expanded'); @@ -166,6 +181,7 @@ function getAbsoluteUrl(url) { }); } + // Update the org level checkboxes based on individual checkbox states in the table function updateMainCheckboxes(table) { table.find('.main-checkbox').each(function () { const column = $(this).data('column'); @@ -175,6 +191,7 @@ function getAbsoluteUrl(url) { } function initializeEventListeners(userId) { + // Toggle organization content visibility $(document).on('click', '.toggle-header', function () { const toggleIcon = $(this).find('.toggle-icon'); const orgContent = $(this).next('.org-content'); @@ -188,7 +205,9 @@ function getAbsoluteUrl(url) { } }); + // Event listener for Individual notification setting $(document).on('change', '.email-checkbox, .web-checkbox', function () { + // Prevent multiple simultaneous updates if (isUpdateInProgress) { return; } @@ -200,8 +219,8 @@ function getAbsoluteUrl(url) { let isWebChecked = $(`.web-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).is(':checked'); let isEmailChecked = $(`.email-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).is(':checked'); + // Store previous states for potential rollback let previousWebChecked, previousEmailChecked; - if (triggeredBy === 'email') { previousEmailChecked = !isEmailChecked; previousWebChecked = isWebChecked; @@ -210,21 +229,19 @@ function getAbsoluteUrl(url) { previousEmailChecked = isEmailChecked; } + // Email notifications require web notifications to be enabled if (triggeredBy === 'email' && isEmailChecked) { isWebChecked = true; } + // Disabling web notifications also disables email notifications if (triggeredBy === 'web' && !isWebChecked) { isEmailChecked = false; } isUpdateInProgress = true; - const data = { - web: isWebChecked, - email: isEmailChecked - }; - + // Update the UI $(`.web-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).prop('checked', isWebChecked); $(`.email-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).prop('checked', isEmailChecked); updateOrgLevelCheckboxes(organizationId); @@ -234,11 +251,12 @@ function getAbsoluteUrl(url) { url: `/api/v1/notifications/user/${userId}/user-setting/${settingId}/`, headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, contentType: 'application/json', - data: JSON.stringify(data), + data: JSON.stringify({ web: isWebChecked, email: isEmailChecked }), success: function () { showToast('success', gettext('Settings updated successfully.')); }, error: function () { + // Rollback changes in case of error showToast('error', gettext('Something went wrong. Please try again.')); $(`.web-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).prop('checked', previousWebChecked); $(`.email-checkbox[data-organization-id="${organizationId}"][data-pk="${settingId}"]`).prop('checked', previousEmailChecked); @@ -250,10 +268,13 @@ function getAbsoluteUrl(url) { }); }); + // Event listener for organization level checkbox changes $(document).on('change', '.main-checkbox', function () { + // Prevent multiple simultaneous updates if (isUpdateInProgress) { return; } + const table = $(this).closest('table'); const orgId = $(this).data('organization-id'); const triggeredBy = $(this).data('column'); @@ -261,8 +282,8 @@ function getAbsoluteUrl(url) { let isOrgWebChecked = $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).is(':checked'); let isOrgEmailChecked = $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).is(':checked'); + // Store previous states for potential rollback let previousOrgWebChecked, previousOrgEmailChecked; - const previousWebState = table.find('.web-checkbox').map(function() { return { id: $(this).data('pk'), checked: $(this).is(':checked') }; }).get(); @@ -279,10 +300,12 @@ function getAbsoluteUrl(url) { previousOrgEmailChecked = isOrgEmailChecked; } + // Email notifications require web notifications to be enabled if (triggeredBy === 'email' && isOrgEmailChecked) { isOrgWebChecked = true; } + // Disabling web notifications also disables email notifications if (triggeredBy === 'web' && !isOrgWebChecked) { isOrgEmailChecked = false; } @@ -294,9 +317,9 @@ function getAbsoluteUrl(url) { email: isOrgEmailChecked }; + // Update the UI $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).prop('checked', isOrgWebChecked); $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).prop('checked', isOrgEmailChecked); - table.find('.web-checkbox').prop('checked', isOrgWebChecked).change(); if ((triggeredBy === 'web' && !isOrgWebChecked) || triggeredBy === 'email') { table.find('.email-checkbox').prop('checked', isOrgEmailChecked).change(); @@ -332,6 +355,7 @@ function getAbsoluteUrl(url) { }); } + // Update individual setting checkboxes at the organization level function updateOrgLevelCheckboxes(organizationId) { const webCheckboxes = $('.web-checkbox[data-organization-id="' + organizationId + '"]'); const emailCheckboxes = $('.email-checkbox[data-organization-id="' + organizationId + '"]'); @@ -341,6 +365,7 @@ function getAbsoluteUrl(url) { emailMainCheckbox.prop('checked', emailCheckboxes.length === emailCheckboxes.filter(':checked').length); } + // Initialize event listener for global settings function initializeGlobalSettingsEventListener(userId) { $('#global-email, #global-web').change(function (event) { if (isUpdateInProgress) { @@ -352,6 +377,7 @@ function getAbsoluteUrl(url) { let isGlobalWebChecked = $('#global-web').is(':checked'); let isGlobalEmailChecked = $('#global-email').is(':checked'); + // Store previous states for potential rollback let previousGlobalWebChecked, previousGlobalEmailChecked; if (triggeredBy === 'global-email') { previousGlobalEmailChecked = !isGlobalEmailChecked; @@ -376,21 +402,19 @@ function getAbsoluteUrl(url) { }).get() }; + // Email notifications require web notifications to be enabled if (triggeredBy === 'global-email' && isGlobalEmailChecked) { isGlobalWebChecked = true; } + // Disabling web notifications also disables email notifications if (triggeredBy === 'global-web' && !isGlobalWebChecked) { isGlobalEmailChecked = false; } isUpdateInProgress = true; - const data = { - web: isGlobalWebChecked, - email: isGlobalEmailChecked - }; - + // Update the UI $('#global-web').prop('checked', isGlobalWebChecked); $('#global-email').prop('checked', isGlobalEmailChecked); @@ -406,7 +430,7 @@ function getAbsoluteUrl(url) { url: getAbsoluteUrl(`/api/v1/notifications/user/${userId}/user-setting/${globalSettingId}/`), headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, contentType: 'application/json', - data: JSON.stringify(data), + data: JSON.stringify({ web: isGlobalWebChecked, email: isGlobalEmailChecked }), success: function () { showToast('success', gettext('Global settings updated successfully.')); }, diff --git a/openwisp_notifications/tests/test_admin.py b/openwisp_notifications/tests/test_admin.py index 0f7c8480..5e73c717 100644 --- a/openwisp_notifications/tests/test_admin.py +++ b/openwisp_notifications/tests/test_admin.py @@ -3,7 +3,6 @@ from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model -from django.contrib.auth.models import Permission from django.core.cache import cache from django.forms.widgets import MediaOrderConflictWarning from django.test import TestCase, override_settings, tag @@ -157,35 +156,33 @@ def test_websocket_protocol(self): response = self.client.get(self._url) self.assertContains(response, 'wss') - def test_org_admin_view_same_org_user_notification_setting(self): - org_owner = self._create_org_user( - user=self._get_operator(), - is_admin=True, - ) - org_admin = self._create_org_user( - user=self._create_user( - username='user', email='user@user.com', is_staff=True - ), - is_admin=True, - ) - permissions = Permission.objects.all() - org_owner.user.user_permissions.set(permissions) - org_admin.user.user_permissions.set(permissions) - self.client.force_login(org_owner.user) - - response = self.client.get( - reverse('admin:openwisp_users_user_change', args=(org_admin.user_id,)), - ) - self.assertEqual(response.status_code, 200) - self.assertNotContains( - response, '' - ) - def test_ignore_notification_widget_add_view(self): url = reverse('admin:openwisp_users_organization_add') response = self.client.get(url) self.assertNotContains(response, 'owIsChangeForm') + def test_notification_preferences_button_staff_user(self): + user = self._create_user(is_staff=True) + user_admin_page = reverse('admin:openwisp_users_user_change', args=(user.pk,)) + expected_url = reverse( + "notifications:user_notification_preference", args=(user.pk,) + ) + expected_html = ( + f'Notification Preferences' + ) + + # Button appears for staff user + with self.subTest("Button should appear for staff user"): + response = self.client.get(user_admin_page) + self.assertContains(response, expected_html, html=True) + + # Button does not appear for non-staff user + with self.subTest("Button should not appear for non-staff user"): + user.is_staff = False + user.save() + response = self.client.get(user_admin_page) + self.assertNotContains(response, expected_html, html=True) + @tag('skip_prod') # For more info, look at TestAdmin.test_default_notification_setting diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index 45ce24ea..3914d1d2 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -559,17 +559,21 @@ def test_notification_setting_list_api(self): def test_list_notification_setting_filtering(self): url = self._get_path('notification_setting_list') - tester = self._create_user() + tester = self._create_administrator( + organizations=[self._get_org(org_name='default')] + ) with self.subTest('Test listing notification setting without filters'): - count = NotificationSetting.objects.count() + count = NotificationSetting.objects.filter(user=self.admin).count() response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data['results']), count) with self.subTest('Test listing notification setting for "default" org'): org = Organization.objects.first() - count = NotificationSetting.objects.filter(organization_id=org.id).count() + count = NotificationSetting.objects.filter( + user=self.admin, organization_id=org.id + ).count() org_url = f'{url}?organization={org.id}' response = self.client.get(org_url) self.assertEqual(response.status_code, 200) @@ -579,7 +583,9 @@ def test_list_notification_setting_filtering(self): with self.subTest('Test listing notification setting for "default" org slug'): org = Organization.objects.first() - count = NotificationSetting.objects.filter(organization=org).count() + count = NotificationSetting.objects.filter( + user=self.admin, organization=org + ).count() org_slug_url = f'{url}?organization_slug={org.slug}' response = self.client.get(org_slug_url) self.assertEqual(response.status_code, 200) @@ -620,15 +626,15 @@ def test_list_notification_setting_filtering(self): self.assertEqual(response.status_code, 403) def test_retreive_notification_setting_api(self): - notification_setting = NotificationSetting.objects.exclude( - organization__isnull=True - ).first() - tester = self._create_user() - tester_notification_setting = NotificationSetting.objects.create( - user=tester, - type='default', - organization=Organization.objects.first(), + tester = self._create_administrator( + organizations=[self._get_org(org_name='default')] ) + notification_setting = NotificationSetting.objects.filter( + user=self.admin, organization__isnull=False + ).first() + tester_notification_setting = NotificationSetting.objects.filter( + user=tester, organization__isnull=False + ).first() with self.subTest('Test for non-existing notification setting'): url = self._get_path('notification_setting', uuid.uuid4()) @@ -682,15 +688,15 @@ def test_retreive_notification_setting_api(self): self.assertEqual(response.status_code, 403) def test_update_notification_setting_api(self): - notification_setting = NotificationSetting.objects.exclude( - organization__isnull=True - ).first() - tester = self._create_user() - tester_notification_setting = NotificationSetting.objects.create( - user=tester, - type='default', - organization=Organization.objects.first(), + tester = self._create_administrator( + organizations=[self._get_org(org_name='default')] ) + notification_setting = NotificationSetting.objects.filter( + user=self.admin, organization__isnull=False + ).first() + tester_notification_setting = NotificationSetting.objects.filter( + user=tester, organization__isnull=False + ).first() update_data = {'web': False} with self.subTest('Test for non-existing notification setting'): @@ -806,25 +812,20 @@ def test_organization_notification_setting_update(self): url = self._get_path( 'organization_notification_setting', self.admin.pk, org.pk ) - response = self.client.post(url, data={'web': True, 'email': True}) - self.assertEqual(response.status_code, 200) - notification_setting = NotificationSetting.objects.filter( + NotificationSetting.objects.filter( user=self.admin, organization_id=org.pk - ).first() - self.assertTrue(notification_setting.web, True) - self.assertTrue(notification_setting.email, True) - - with self.subTest('Test for admin user'): - url = self._get_path( - 'organization_notification_setting', self.admin.pk, org.pk - ) + ).update(email=False, web=False) + org_setting_count = NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk + ).count() response = self.client.post(url, data={'web': True, 'email': True}) self.assertEqual(response.status_code, 200) - notification_setting = NotificationSetting.objects.filter( - user=self.admin, organization_id=org.pk - ).first() - self.assertTrue(notification_setting.web, True) - self.assertTrue(notification_setting.email, True) + self.assertEqual( + NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk, email=True, web=True + ).count(), + org_setting_count, + ) with self.subTest('Test for non-admin user'): self.client.force_login(tester) @@ -846,6 +847,28 @@ def test_organization_notification_setting_update(self): response = self.client.post(url, data={'web': 'invalid'}) self.assertEqual(response.status_code, 400) + with self.subTest( + 'Update organization-level web to False while keeping one of email notification setting to true' + ): + url = self._get_path( + 'organization_notification_setting', + self.admin.pk, + org.pk, + ) + + # Set the default type notification setting's email to True + NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk, type='default' + ).update(email=True) + + response = self.client.post(url, data={'web': True, 'email': False}) + + self.assertTrue( + NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk, type='default', email=True + ).exists() + ) + @patch('openwisp_notifications.tasks.delete_ignore_object_notification.apply_async') def test_create_ignore_obj_notification_api(self, mocked_task): org_user = self._get_org_user() diff --git a/openwisp_notifications/tests/test_notification_setting.py b/openwisp_notifications/tests/test_notification_setting.py index 8156c44e..f89b6a0c 100644 --- a/openwisp_notifications/tests/test_notification_setting.py +++ b/openwisp_notifications/tests/test_notification_setting.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db.models.signals import post_save from django.test import TransactionTestCase @@ -106,6 +107,16 @@ def test_post_migration_handler(self): base_unregister_notification_type('default') base_register_notification_type('test', test_notification_type) + + # Delete existing global notification settings + NotificationSetting.objects.filter( + user=org_user.user, type=None, organization=None + ).delete() + + NotificationSetting.objects.filter( + user=admin, type=None, organization=None + ).delete() + notification_type_registered_unregistered_handler(sender=self) # Notification Setting for "default" type are deleted @@ -122,19 +133,19 @@ def test_post_migration_handler(self): queryset.filter(user=org_user.user).count(), 1 * notification_types_count ) - # Delete existing global notification settings - NotificationSetting.objects.filter( - user=admin, type=None, organization=None - ).delete() - # Check Global Notification Setting is created - notification_type_registered_unregistered_handler(sender=admin) self.assertEqual( NotificationSetting.objects.filter( user=admin, type=None, organization=None ).count(), 1, ) + self.assertEqual( + NotificationSetting.objects.filter( + user=org_user.user, type=None, organization=None + ).count(), + 1, + ) def test_superuser_demoted_to_user(self): admin = self._get_admin() @@ -266,6 +277,35 @@ def test_deleted_notificationsetting_autocreated(self): ns.refresh_from_db() self.assertEqual(ns.deleted, False) + def test_global_notification_setting_update(self): + admin = self._get_admin() + org = self._get_org('default') + global_setting = NotificationSetting.objects.get( + user=admin, type=None, organization=None + ) + + # Update global settings + global_setting.email = False + global_setting.web = False + global_setting.save() + + with self.subTest( + 'Update global web to False while ensuring at least one email setting is True' + ): + # Set the default type notification setting's email to True + NotificationSetting.objects.filter( + user=admin, organization=org, type='default' + ).update(email=True) + + global_setting.web = True + global_setting.save() + + self.assertTrue( + NotificationSetting.objects.filter( + user=admin, organization=org, email=True, type='default' + ).exists() + ) + def test_global_notification_setting_delete(self): admin = self._get_admin() global_setting = NotificationSetting.objects.get( @@ -279,3 +319,24 @@ def test_global_notification_setting_delete(self): ).count(), 0, ) + + def test_validate_global_notification_setting(self): + admin = self._get_admin() + with self.subTest('Test global notification setting creation'): + NotificationSetting.objects.filter( + user=admin, organization=None, type=None + ).delete() + global_setting = NotificationSetting( + user=admin, organization=None, type=None, email=True, web=True + ) + global_setting.full_clean() + global_setting.save() + self.assertIsNotNone(global_setting) + + with self.subTest('Test only one global notification setting per user'): + global_setting = NotificationSetting( + user=admin, organization=None, type=None, email=True, web=True + ) + with self.assertRaises(ValidationError): + global_setting.full_clean() + global_setting.save() diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index 3e6d1b92..2f7c3430 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.core import mail from django.core.cache import cache -from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.exceptions import ImproperlyConfigured from django.db.models.signals import post_migrate, post_save from django.template import TemplateDoesNotExist from django.test import TransactionTestCase @@ -950,24 +950,6 @@ def test_notification_for_unverified_email(self): # we don't send emails to unverified email addresses self.assertEqual(len(mail.outbox), 0) - def test_validate_global_notification_setting(self): - with self.subTest('Test global notification setting creation'): - NotificationSetting.objects.filter( - user=self.admin, organization=None, type=None - ).delete() - global_setting = NotificationSetting( - user=self.admin, organization=None, type=None, email=True, web=True - ) - global_setting.save() - self.assertIsNotNone(global_setting) - - with self.subTest('Test only one global notification setting per user'): - global_setting = NotificationSetting( - user=self.admin, organization=None, type=None, email=True, web=True - ) - with self.assertRaises(ValidationError): - global_setting.save() - @patch('openwisp_notifications.tasks.send_batched_email_notifications.apply_async') def test_batch_email_notification(self, mock_send_email): fixed_datetime = datetime(2024, 7, 26, 11, 40) diff --git a/openwisp_notifications/tests/test_selenium.py b/openwisp_notifications/tests/test_selenium.py index 629d2130..8d0d2548 100644 --- a/openwisp_notifications/tests/test_selenium.py +++ b/openwisp_notifications/tests/test_selenium.py @@ -3,10 +3,14 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait -from openwisp_notifications.swapper import swapper_load_model +from openwisp_notifications.signals import notify +from openwisp_notifications.swapper import load_model, swapper_load_model +from openwisp_notifications.utils import _get_object_link from openwisp_users.tests.utils import TestOrganizationMixin from openwisp_utils.test_selenium_mixins import SeleniumTestMixin +Notification = load_model('Notification') +Organization = swapper_load_model('openwisp_users', 'Organization') OrganizationUser = swapper_load_model('openwisp_users', 'OrganizationUser') @@ -15,55 +19,133 @@ class TestSelenium( TestOrganizationMixin, StaticLiveServerTestCase, ): - serve_static = True - def setUp(self): self.admin = self._create_admin( username=self.admin_username, password=self.admin_password ) - self.driver = self.web_driver org = self._create_org() OrganizationUser.objects.create(user=self.admin, organization=org) + self.operator = super()._get_operator() + self.notification_options = dict( + sender=self.admin, + recipient=self.admin, + verb='Test Notification', + email_subject='Test Email subject', + action_object=self.operator, + target=self.operator, + type='default', + ) + + def _create_notification(self): + return notify.send(**self.notification_options) + + def test_notification_relative_link(self): + self.login() + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-notification-elem')) + ) + notification_elem = self.web_driver.find_element( + By.CLASS_NAME, 'ow-notification-elem' + ) + data_location_value = notification_elem.get_attribute('data-location') + self.assertEqual( + data_location_value, _get_object_link(notification, 'target', False) + ) + + def test_notification_dialog(self): + self.login() + self.notification_options.update( + {'message': 'Test Message', 'description': 'Test Description'} + ) + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) + ) + self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) + ) + dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') + self.assertEqual( + dialog.find_element(By.CLASS_NAME, 'ow-message-title').text, 'Test Message' + ) + self.assertEqual( + dialog.find_element(By.CLASS_NAME, 'ow-message-description').text, + 'Test Description', + ) - def open(self, url, driver=None): - driver = self.driver or self.web_driver - driver.get(f'{self.live_server_url}{url}') + def test_notification_dialog_open_button_visibility(self): + self.login() + self.notification_options.pop('target') + self.notification_options.update( + {'message': 'Test Message', 'description': 'Test Description'} + ) + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) + ) + self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) + ) + dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') + # This confirms the button is hidden + dialog.find_element(By.CSS_SELECTOR, '.ow-message-target-redirect.ow-hide') def test_notification_preference_page(self): self.login() self.open('/notifications/preferences/') - self.driver.implicitly_wait(1) # Uncheck the global web checkbox - global_web_checkbox = WebDriverWait(self.driver, 30).until( - EC.presence_of_element_located((By.ID, 'global-web')) + global_web_label = WebDriverWait(self.web_driver, 30).until( + EC.element_to_be_clickable( + (By.XPATH, "//*[@id='global-web']/parent::label") + ) ) - global_web_label = global_web_checkbox.find_element(By.XPATH, './parent::label') global_web_label.click() - all_checkboxes = self.driver.find_elements( + all_checkboxes = self.web_driver.find_elements( By.CSS_SELECTOR, 'input[type="checkbox"]' ) for checkbox in all_checkboxes: self.assertFalse(checkbox.is_selected()) # Check the org-level web checkbox - org_level_web_checkbox = WebDriverWait(self.driver, 10).until( + org_level_web_checkbox = WebDriverWait(self.web_driver, 10).until( EC.visibility_of_element_located((By.ID, 'org-1-web')) ) org_level_web_checkbox.click() - web_checkboxes = self.driver.find_elements( + web_checkboxes = self.web_driver.find_elements( By.CSS_SELECTOR, 'input[id^="org-1-web-"]' ) for checkbox in web_checkboxes: self.assertTrue(checkbox.is_selected()) # Check a single email checkbox - first_org_email_checkbox = WebDriverWait(self.driver, 10).until( + first_org_email_checkbox = WebDriverWait(self.web_driver, 10).until( EC.presence_of_element_located((By.ID, 'org-1-email-1')) ) first_org_email_checkbox.click() self.assertTrue( first_org_email_checkbox.find_element(By.TAG_NAME, 'input').is_selected() ) + + def test_empty_notification_preference_page(self): + # Delete all organizations + Organization.objects.all().delete() + + self.login() + self.open('/notifications/preferences/') + + no_organizations_element = WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'no-organizations')) + ) + self.assertEqual( + no_organizations_element.text, + 'No organizations available.', + ) diff --git a/openwisp_notifications/tests/test_utils.py b/openwisp_notifications/tests/test_utils.py index a4cdc9a0..7bd17d22 100644 --- a/openwisp_notifications/tests/test_utils.py +++ b/openwisp_notifications/tests/test_utils.py @@ -105,3 +105,12 @@ def run_check(): ) error = run_check() self.assertIn(error_message, error.hint) + + with self.subTest('Test setting dotted path is not subclass of ModelAdmin'): + path = 'openwisp_users.admin.OrganizationUserInline' + with patch.object(app_settings, 'IGNORE_ENABLED_ADMIN', [path]): + error_message = ( + f'"{path}" does not subclasses "django.contrib.admin.ModelAdmin"' + ) + error = run_check() + self.assertIn(error_message, error.hint) diff --git a/openwisp_notifications/tests/test_widget.py b/openwisp_notifications/tests/test_widget.py deleted file mode 100644 index 48bebeca..00000000 --- a/openwisp_notifications/tests/test_widget.py +++ /dev/null @@ -1,95 +0,0 @@ -from django.contrib.staticfiles.testing import StaticLiveServerTestCase -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait - -from openwisp_notifications.signals import notify -from openwisp_notifications.swapper import load_model -from openwisp_notifications.utils import _get_object_link -from openwisp_users.tests.utils import TestOrganizationMixin -from openwisp_utils.test_selenium_mixins import SeleniumTestMixin - -Notification = load_model('Notification') - - -class TestWidget( - SeleniumTestMixin, - TestOrganizationMixin, - StaticLiveServerTestCase, -): - serve_static = True - - def setUp(self): - self.admin = self._create_admin( - username=self.admin_username, password=self.admin_password - ) - self.operator = super()._get_operator() - self.notification_options = dict( - sender=self.admin, - recipient=self.admin, - verb='Test Notification', - email_subject='Test Email subject', - action_object=self.operator, - target=self.operator, - type='default', - ) - - def _create_notification(self): - return notify.send(**self.notification_options) - - def test_notification_relative_link(self): - self.login() - notification = self._create_notification().pop()[1][0] - self.web_driver.find_element(By.ID, 'openwisp_notifications').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.CLASS_NAME, 'ow-notification-elem')) - ) - notification_elem = self.web_driver.find_element( - By.CLASS_NAME, 'ow-notification-elem' - ) - data_location_value = notification_elem.get_attribute('data-location') - self.assertEqual( - data_location_value, _get_object_link(notification, 'target', False) - ) - - def test_notification_dialog(self): - self.login() - self.notification_options.update( - {'message': 'Test Message', 'description': 'Test Description'} - ) - notification = self._create_notification().pop()[1][0] - self.web_driver.find_element(By.ID, 'openwisp_notifications').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) - ) - self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) - ) - dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') - self.assertEqual( - dialog.find_element(By.CLASS_NAME, 'ow-message-title').text, 'Test Message' - ) - self.assertEqual( - dialog.find_element(By.CLASS_NAME, 'ow-message-description').text, - 'Test Description', - ) - - def test_notification_dialog_open_button_visibility(self): - self.login() - self.notification_options.pop('target') - self.notification_options.update( - {'message': 'Test Message', 'description': 'Test Description'} - ) - notification = self._create_notification().pop()[1][0] - self.web_driver.find_element(By.ID, 'openwisp_notifications').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) - ) - self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) - ) - dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') - # This confirms the button is hidden - dialog.find_element(By.CSS_SELECTOR, '.ow-message-target-redirect.ow-hide') diff --git a/openwisp_notifications/views.py b/openwisp_notifications/views.py index fdb06676..281afa27 100644 --- a/openwisp_notifications/views.py +++ b/openwisp_notifications/views.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.http import Http404 +from django.urls import reverse_lazy from django.utils.translation import gettext as _ from django.views.generic import TemplateView @@ -9,7 +10,7 @@ class NotificationPreferencePage(LoginRequiredMixin, UserPassesTestMixin, TemplateView): template_name = 'openwisp_notifications/preferences.html' - login_url = '/admin/login/' + login_url = reverse_lazy('admin:login') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization_and_more.py similarity index 52% rename from tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py rename to tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization_and_more.py index 7ab64f70..e8c94403 100644 --- a/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization.py +++ b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-06-20 13:02 +# Generated by Django 4.2.16 on 2024-09-17 13:27 import django.db.models.deletion from django.db import migrations, models @@ -21,4 +21,19 @@ class Migration(migrations.Migration): to="openwisp_users.organization", ), ), + migrations.AlterField( + model_name="notificationsetting", + name="type", + field=models.CharField( + blank=True, + choices=[ + ("default", "Default Type"), + ("generic_message", "Generic Message Type"), + ("object_created", "Object created"), + ], + max_length=30, + null=True, + verbose_name="Notification Type", + ), + ), ] From c3f01e543efdc38ca0358d4434ac2677918bd986 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Fri, 20 Sep 2024 16:12:45 +0530 Subject: [PATCH 71/74] [chore] Update org level changes --- openwisp_notifications/api/serializers.py | 10 +++++-- openwisp_notifications/api/views.py | 19 ++---------- .../openwisp-notifications/js/preferences.js | 5 +++- openwisp_notifications/tests/test_api.py | 29 +++++++++++++++++-- 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/openwisp_notifications/api/serializers.py b/openwisp_notifications/api/serializers.py index dba0b9b5..676059d0 100644 --- a/openwisp_notifications/api/serializers.py +++ b/openwisp_notifications/api/serializers.py @@ -95,5 +95,11 @@ class Meta: class NotificationSettingUpdateSerializer(serializers.Serializer): - email = serializers.BooleanField() - web = serializers.BooleanField() + email = serializers.BooleanField(required=False) + web = serializers.BooleanField(required=False) + + def validate(self, attrs): + attrs = super().validate(attrs) + if 'email' not in attrs and attrs.get('web') is False: + attrs['email'] = False + return attrs diff --git a/openwisp_notifications/api/views.py b/openwisp_notifications/api/views.py index 4dcc5216..b50d2160 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -206,25 +206,12 @@ class OrganizationNotificationSettingView(GenericAPIView): serializer_class = NotificationSettingUpdateSerializer def post(self, request, user_id, organization_id): - notification_settings = NotificationSetting.objects.filter( - organization_id=organization_id, user_id=user_id - ) serializer = self.get_serializer(data=request.data) if serializer.is_valid(): validated_data = serializer.validated_data - web = validated_data.get('web') - email = validated_data.get('email') - - for notification_setting in notification_settings: - if web and not email: - notification_setting.web = web - else: - notification_setting.web = web - notification_setting.email = email - - NotificationSetting.objects.bulk_update( - notification_settings, ['web', 'email'] - ) + NotificationSetting.objects.filter( + organization_id=organization_id, user_id=user_id + ).update(**validated_data) return Response(status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/openwisp_notifications/static/openwisp-notifications/js/preferences.js b/openwisp_notifications/static/openwisp-notifications/js/preferences.js index af538bec..13906d87 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/preferences.js +++ b/openwisp_notifications/static/openwisp-notifications/js/preferences.js @@ -314,9 +314,12 @@ function getAbsoluteUrl(url) { const data = { web: isOrgWebChecked, - email: isOrgEmailChecked }; + if (triggeredBy === 'email') { + data.email = isOrgEmailChecked; + } + // Update the UI $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).prop('checked', isOrgWebChecked); $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).prop('checked', isOrgEmailChecked); diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index 3914d1d2..09fdfa5a 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -848,7 +848,7 @@ def test_organization_notification_setting_update(self): self.assertEqual(response.status_code, 400) with self.subTest( - 'Update organization-level web to False while keeping one of email notification setting to true' + 'Test email to False while keeping one of email notification setting to true' ): url = self._get_path( 'organization_notification_setting', @@ -856,6 +856,10 @@ def test_organization_notification_setting_update(self): org.pk, ) + NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk + ).update(web=False, email=False) + # Set the default type notification setting's email to True NotificationSetting.objects.filter( user=self.admin, organization_id=org.pk, type='default' @@ -863,9 +867,28 @@ def test_organization_notification_setting_update(self): response = self.client.post(url, data={'web': True, 'email': False}) - self.assertTrue( + self.assertFalse( + NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk, email=True + ).exists() + ) + + with self.subTest('Test web to False'): + url = self._get_path( + 'organization_notification_setting', + self.admin.pk, + org.pk, + ) + + NotificationSetting.objects.filter( + user=self.admin, organization_id=org.pk + ).update(web=True, email=True) + + response = self.client.post(url, data={'web': False}) + + self.assertFalse( NotificationSetting.objects.filter( - user=self.admin, organization_id=org.pk, type='default', email=True + user=self.admin, organization_id=org.pk, email=True ).exists() ) From ad8ff56e04d7b1e4bf4753c8a32d69822be308ae Mon Sep 17 00:00:00 2001 From: Dhanus Date: Fri, 20 Sep 2024 17:57:33 +0530 Subject: [PATCH 72/74] [chore] Use full_clean instead of saving directly --- openwisp_notifications/tests/test_admin.py | 1 + openwisp_notifications/tests/test_notification_setting.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/openwisp_notifications/tests/test_admin.py b/openwisp_notifications/tests/test_admin.py index 5e73c717..35e48ef4 100644 --- a/openwisp_notifications/tests/test_admin.py +++ b/openwisp_notifications/tests/test_admin.py @@ -179,6 +179,7 @@ def test_notification_preferences_button_staff_user(self): # Button does not appear for non-staff user with self.subTest("Button should not appear for non-staff user"): user.is_staff = False + user.full_clean() user.save() response = self.client.get(user_admin_page) self.assertNotContains(response, expected_html, html=True) diff --git a/openwisp_notifications/tests/test_notification_setting.py b/openwisp_notifications/tests/test_notification_setting.py index f89b6a0c..44c9ac1b 100644 --- a/openwisp_notifications/tests/test_notification_setting.py +++ b/openwisp_notifications/tests/test_notification_setting.py @@ -287,6 +287,7 @@ def test_global_notification_setting_update(self): # Update global settings global_setting.email = False global_setting.web = False + global_setting.full_clean() global_setting.save() with self.subTest( @@ -298,6 +299,7 @@ def test_global_notification_setting_update(self): ).update(email=True) global_setting.web = True + global_setting.full_clean() global_setting.save() self.assertTrue( From 6c53bf677cedddffe3a82c1069d6318c774ecdaf Mon Sep 17 00:00:00 2001 From: Dhanus Date: Fri, 20 Sep 2024 19:28:05 +0530 Subject: [PATCH 73/74] [chore] Update global notification setting changes --- openwisp_notifications/base/models.py | 14 +++++++++++--- .../tests/test_notification_setting.py | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index 43417a17..29e33778 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -309,11 +309,19 @@ def save(self, *args, **kwargs): with transaction.atomic(): if not self.organization and not self.type: try: - original = self.__class__.objects.get(pk=self.pk) + previous_state = self.__class__.objects.only('email').get( + pk=self.pk + ) updates = {'web': self.web} - # Update 'email' only if it's different from the previous state - if not self.web or self.email != original.email: + # If global web notifiations are disabled, then disable email notifications as well + if not self.web: + updates['email'] = False + + # Update email notifiations only if it's different from the previous state + # Otherwise, it would overwrite the email notification settings for specific + # setting that were enabled by the user after disabling global email notifications + if self.email != previous_state.email: updates['email'] = self.email self.user.notificationsetting_set.exclude(pk=self.pk).update( diff --git a/openwisp_notifications/tests/test_notification_setting.py b/openwisp_notifications/tests/test_notification_setting.py index 44c9ac1b..16a2712b 100644 --- a/openwisp_notifications/tests/test_notification_setting.py +++ b/openwisp_notifications/tests/test_notification_setting.py @@ -291,7 +291,7 @@ def test_global_notification_setting_update(self): global_setting.save() with self.subTest( - 'Update global web to False while ensuring at least one email setting is True' + 'Test global web to False while ensuring at least one email setting is True' ): # Set the default type notification setting's email to True NotificationSetting.objects.filter( @@ -308,6 +308,22 @@ def test_global_notification_setting_update(self): ).exists() ) + with self.subTest('Test global web to False'): + global_setting.web = False + global_setting.full_clean() + global_setting.save() + + self.assertFalse( + NotificationSetting.objects.filter( + user=admin, organization=org, web=True + ).exists() + ) + self.assertFalse( + NotificationSetting.objects.filter( + user=admin, organization=org, email=True + ).exists() + ) + def test_global_notification_setting_delete(self): admin = self._get_admin() global_setting = NotificationSetting.objects.get( From 2836bbda4490b76aa699589524cde52f40dc89b5 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Mon, 30 Sep 2024 20:53:38 +0530 Subject: [PATCH 74/74] [chore] UI changes --- .../css/preferences.css | 133 ++++- .../images/icons/icon-email.svg | 3 + .../images/icons/icon-web.svg | 3 + .../openwisp-notifications/js/preferences.js | 558 +++++++++++++----- .../openwisp_notifications/preferences.html | 67 ++- openwisp_notifications/tests/test_selenium.py | 47 +- 6 files changed, 649 insertions(+), 162 deletions(-) create mode 100644 openwisp_notifications/static/openwisp-notifications/images/icons/icon-email.svg create mode 100644 openwisp_notifications/static/openwisp-notifications/images/icons/icon-web.svg diff --git a/openwisp_notifications/static/openwisp-notifications/css/preferences.css b/openwisp_notifications/static/openwisp-notifications/css/preferences.css index 982cda1e..ce5f990a 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/preferences.css +++ b/openwisp_notifications/static/openwisp-notifications/css/preferences.css @@ -3,23 +3,128 @@ display: none; } .global-settings-container { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; + display: flex; + border: 1px solid #e0e0e0; + border-radius: 8px; +} +.icon { + min-width: 24px; + min-height: 24px; + padding-right: 6px; +} +.icon-web { + background: url("../../openwisp-notifications/images/icons/icon-web.svg") + 0 0 no-repeat; +} +.icon-email { + background: url("../../openwisp-notifications/images/icons/icon-email.svg") + 0 0 no-repeat; +} +.global-setting-text h2 { + margin: 0 0 5px 0; +} +.global-setting-text p { + color: #666; +} +.global-setting-divider { + width: 1px; + background-color: #e0e0e0; +} +.global-setting-dropdown { + position: relative; +} +.global-setting-dropdown-toggle .mg-arrow { + display: block; +} +.global-setting-dropdown-toggle { + display: flex; + padding: 10px 15px; + background-color: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; +} +.global-setting-dropdown-menu { + display: none; + position: absolute; + background-color: #fff; + border: 1px solid #e0e0e0; + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 0; + margin: 0; +} +.global-setting-dropdown-menu li { + padding: 10px 15px; + cursor: pointer; +} +.global-setting-dropdown-menu-open { + display: block; +} +.global-setting { + flex: 1; + padding: 20px; } .global-settings-container { - margin-left: 20px; + width: 840px; +} +.global-setting-content { + display: flex; + margin-bottom: 10px; +} +.global-setting-content h2 { + color: #555; +} +.modal { + display: none; + position: fixed; + z-index: 9999; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.4); +} +.modal-content { + background-color: #fff; + margin: 15% auto; + padding: 20px; + border: 1px solid #888; + width: 400px; + border-radius: 5px; +} +.modal-header { + margin-bottom: 20px; +} +.modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} +#go-back, +#confirm { + width: 100%; } .module h2 { padding: 15px 10px; cursor: pointer; display: flex; - justify-content: space-between; + justify-content: space-around; align-items: center; font-weight: bold; font-size: 14px; text-transform: uppercase; + padding: 6px; +} +.toggle-header { + border: none !important; +} +.org-name { + width: 40%; +} +.email-row { + position: relative; } .org-content { margin-top: 0; @@ -32,8 +137,8 @@ table { width: 100%; } -th:not(:last-child), -td:not(:last-child) { +table:not(.toggle-header) th:not(:last-child), +table:not(.toggle-header) td:not(:last-child) { border-right: 1px solid #ddd; } th:not(:first-child), @@ -58,6 +163,9 @@ td:not(:first-child) { z-index: 9999; cursor: pointer; } +.toast .icon { + background-repeat: no-repeat; +} .toast .progress-bar { position: absolute; bottom: 0; @@ -68,6 +176,9 @@ td:not(:first-child) { transition: width 3s linear; } span.toggle-icon { + position: absolute; + right: 15px; + top: 15px; width: 16px; height: 16px; margin-right: 5px; @@ -157,7 +268,8 @@ input:checked + .slider:before { -ms-transform: translateX(20px); transform: translateX(20px); } -.notification-web-header, .notification-email-header { +.notification-web-header, +.notification-email-header { text-align: center; } .notification-header-container { @@ -175,3 +287,6 @@ input:checked + .slider:before { .module { border: 1px solid rgba(0, 0, 0, 0.1); } +ul > li { + list-style-type: none !important; +} diff --git a/openwisp_notifications/static/openwisp-notifications/images/icons/icon-email.svg b/openwisp_notifications/static/openwisp-notifications/images/icons/icon-email.svg new file mode 100644 index 00000000..a1816b7c --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/images/icons/icon-email.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwisp_notifications/static/openwisp-notifications/images/icons/icon-web.svg b/openwisp_notifications/static/openwisp-notifications/images/icons/icon-web.svg new file mode 100644 index 00000000..212c6ae1 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/images/icons/icon-web.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwisp_notifications/static/openwisp-notifications/js/preferences.js b/openwisp_notifications/static/openwisp-notifications/js/preferences.js index 13906d87..c1a9cc89 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/preferences.js +++ b/openwisp_notifications/static/openwisp-notifications/js/preferences.js @@ -15,6 +15,7 @@ function getAbsoluteUrl(url) { $(document).ready(function () { const userId = $('.settings-container').data('user-id'); fetchNotificationSettings(userId); + initializeGlobalSettings(userId); }); function fetchNotificationSettings(userId) { @@ -62,10 +63,7 @@ function getAbsoluteUrl(url) { const isGlobalEmailChecked = globalSetting.email; globalSettingId = globalSetting.id; - $('#global-web').prop('checked', isGlobalWebChecked); - $('#global-email').prop('checked', isGlobalEmailChecked); - - initializeGlobalSettingsEventListener(userId); + initializeGlobalDropdowns(isGlobalWebChecked, isGlobalEmailChecked); } else { showToast('error', gettext('Global settings not found.')); } @@ -78,6 +76,26 @@ function getAbsoluteUrl(url) { $('.global-settings').show(); } + function initializeGlobalDropdowns(isGlobalWebChecked, isGlobalEmailChecked) { + // Initialize Web dropdown + const webDropdown = document.querySelector('.global-setting-dropdown[data-web-state]'); + const webToggle = webDropdown.querySelector('.global-setting-dropdown-toggle'); + const webState = isGlobalWebChecked ? 'on' : 'off'; + + // Update toggle's data-state and button text + webToggle.setAttribute('data-state', webState); + webToggle.innerHTML = (isGlobalWebChecked ? 'Notify on Web' : 'Don\'t Notify on Web') + ' ' + createArrowSpanHtml(); + + // Initialize Email dropdown + const emailDropdown = document.querySelector('.global-setting-dropdown[data-email-state]'); + const emailToggle = emailDropdown.querySelector('.global-setting-dropdown-toggle'); + const emailState = isGlobalEmailChecked ? 'on' : 'off'; + + // Update toggle's data-state and button text + emailToggle.setAttribute('data-state', emailState); + emailToggle.innerHTML = (isGlobalEmailChecked ? 'Notify by Email' : 'Don\'t Notify by Email') + ' ' + createArrowSpanHtml(); + } + function groupBy(array, key) { return array.reduce((result, currentValue) => { (result[currentValue[key]] = result[currentValue[key]] || []).push(currentValue); @@ -101,10 +119,23 @@ function getAbsoluteUrl(url) { const orgName = orgSettings[0].organization_name; + // Calculate counts + const totalNotifications = orgSettings.length; + const enabledWebNotifications = orgSettings.filter(setting => setting.web).length; + const enabledEmailNotifications = orgSettings.filter(setting => setting.email).length; + const orgPanel = $( '
' + - '

' + `${gettext('Organization')}: ${orgName}` + '

' + - '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '

' + `${gettext('Organization')}: ${orgName}` + '

' + gettext('Web') + ' ' + enabledWebNotifications + '/' + totalNotifications + '

' + + '
' + '
' ); @@ -112,32 +143,32 @@ function getAbsoluteUrl(url) { if (orgSettings.length > 0) { const table = $( '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + '
' + gettext('Notification Type') + '' + - '
' + - '' + gettext('Web') + '' + - '?' + - '' + - '
' + - '
' + - '
' + - '' + gettext('Email') + '' + - '?' + - '' + - '
' + - '
' + gettext('Notification Type') + '' + + '
' + + '' + gettext('Web') + '' + + '?' + + '' + + '
' + + '
' + + '
' + + '' + gettext('Email') + '' + + '?' + + '' + + '
' + + '
' ); @@ -145,19 +176,19 @@ function getAbsoluteUrl(url) { orgSettings.forEach(function(setting, settingIndex) { const row = $( '' + - '' + setting.type_label + '' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + setting.type_label + '' + + '' + + '' + + '' + + '' + + '' + + '' + '' ); table.find('tbody').append(row); @@ -171,8 +202,8 @@ function getAbsoluteUrl(url) { orgPanelsContainer.append(orgPanel); - // Expand the first organization by default - if (orgIndex === 0) { + // Expand the first organization if there is only one organization + if (orgIndex === 0 && orgSettings.length === 1) { orgContent.addClass('active'); orgPanel.find('.toggle-icon').removeClass('collapsed').addClass('expanded'); } else { @@ -181,12 +212,18 @@ function getAbsoluteUrl(url) { }); } - // Update the org level checkboxes based on individual checkbox states in the table + // Update the org level checkboxes function updateMainCheckboxes(table) { table.find('.main-checkbox').each(function () { const column = $(this).data('column'); - const allChecked = table.find('.' + column + '-checkbox').length === table.find('.' + column + '-checkbox:checked').length; + const totalCheckboxes = table.find('.' + column + '-checkbox').length; + const checkedCheckboxes = table.find('.' + column + '-checkbox:checked').length; + const allChecked = totalCheckboxes === checkedCheckboxes; $(this).prop('checked', allChecked); + + // Update counts in the header + const headerSpan = table.find('.notification-' + column + '-header .notification-header-container span').first(); + headerSpan.text((column === 'web' ? gettext('Web') : gettext('Email')) + ' ' + checkedCheckboxes + '/' + totalCheckboxes); }); } @@ -329,6 +366,7 @@ function getAbsoluteUrl(url) { } updateMainCheckboxes(table); + updateOrgLevelCheckboxes(orgId); $.ajax({ type: 'POST', @@ -358,109 +396,361 @@ function getAbsoluteUrl(url) { }); } - // Update individual setting checkboxes at the organization level + // Update individual setting checkboxes and counts at the organization level function updateOrgLevelCheckboxes(organizationId) { - const webCheckboxes = $('.web-checkbox[data-organization-id="' + organizationId + '"]'); - const emailCheckboxes = $('.email-checkbox[data-organization-id="' + organizationId + '"]'); - const webMainCheckbox = $('.main-checkbox[data-column="web"][data-organization-id="' + organizationId + '"]'); - const emailMainCheckbox = $('.main-checkbox[data-column="email"][data-organization-id="' + organizationId + '"]'); - webMainCheckbox.prop('checked', webCheckboxes.length === webCheckboxes.filter(':checked').length); - emailMainCheckbox.prop('checked', emailCheckboxes.length === emailCheckboxes.filter(':checked').length); + const table = $(`.main-checkbox[data-organization-id="${organizationId}"]`).closest('table'); + const webCheckboxes = table.find('.web-checkbox'); + const emailCheckboxes = table.find('.email-checkbox'); + const webMainCheckbox = table.find('.main-checkbox[data-column="web"]'); + const emailMainCheckbox = table.find('.main-checkbox[data-column="email"]'); + const totalWebCheckboxes = webCheckboxes.length; + const totalEmailCheckboxes = emailCheckboxes.length; + const checkedWebCheckboxes = webCheckboxes.filter(':checked').length; + const checkedEmailCheckboxes = emailCheckboxes.filter(':checked').length; + + webMainCheckbox.prop('checked', totalWebCheckboxes === checkedWebCheckboxes); + emailMainCheckbox.prop('checked', totalEmailCheckboxes === checkedEmailCheckboxes); + + // Update counts in the header + const orgModule = table.closest('.module'); + const webCountSpan = orgModule.find('.web-count'); + const emailCountSpan = orgModule.find('.email-count'); + webCountSpan.text(gettext('Web') + ' ' + checkedWebCheckboxes + '/' + totalWebCheckboxes); + emailCountSpan.text(gettext('Email') + ' ' + checkedEmailCheckboxes + '/' + totalEmailCheckboxes); } - // Initialize event listener for global settings - function initializeGlobalSettingsEventListener(userId) { - $('#global-email, #global-web').change(function (event) { + function initializeGlobalSettings(userId) { + var $dropdowns = $(".global-setting-dropdown"); + var $modal = $("#confirmation-modal"); + var $goBackBtn = $("#go-back"); + var $confirmBtn = $("#confirm"); + var activeDropdown = null; + var selectedOptionText = ""; + var selectedOptionElement = null; + var previousCheckboxStates = null; + + $dropdowns.each(function () { + var $dropdown = $(this); + var $toggle = $dropdown.find(".global-setting-dropdown-toggle"); + var $menu = $dropdown.find(".global-setting-dropdown-menu"); + + $toggle.on("click", function (e) { + e.stopPropagation(); + closeAllDropdowns(); + $menu.toggleClass("global-setting-dropdown-menu-open"); + adjustDropdownWidth($menu); + }); + + $menu.find("li").on("click", function () { + activeDropdown = $dropdown; + selectedOptionText = $(this).text().trim(); + selectedOptionElement = $(this); + updateModalContent(); // Update modal content before showing + $modal.show(); + }); + }); + + // Close all dropdowns when clicking outside + $(document).on("click", closeAllDropdowns); + + function closeAllDropdowns() { + $dropdowns.each(function () { + $(this) + .find(".global-setting-dropdown-menu") + .removeClass("global-setting-dropdown-menu-open"); + }); + } + + function adjustDropdownWidth($menu) { + var $toggle = $menu.prev(".global-setting-dropdown-toggle"); + var maxWidth = Math.max.apply( + null, + $menu + .find("li") + .map(function () { + return $(this).outerWidth(); + }) + .get() + ); + $menu.css( + "width", + Math.max($toggle.outerWidth(), maxWidth) + "px" + ); + } + + $goBackBtn.on("click", function () { + $modal.hide(); + }); + + $confirmBtn.on("click", function () { if (isUpdateInProgress) { return; } - const triggeredBy = $(event.target).attr('id'); + if (activeDropdown) { + var dropdownType = activeDropdown.is("[data-web-state]") ? "web" : "email"; + var triggeredBy = dropdownType; + + var $webDropdown = $('.global-setting-dropdown[data-web-state]'); + var $emailDropdown = $('.global-setting-dropdown[data-email-state]'); + var $webToggle = $webDropdown.find('.global-setting-dropdown-toggle'); + var $emailToggle = $emailDropdown.find('.global-setting-dropdown-toggle'); + + // Determine the current states + var isGlobalWebChecked = $webToggle.attr('data-state') === 'on'; + var isGlobalEmailChecked = $emailToggle.attr('data-state') === 'on'; + + // Store previous states for potential rollback + var previousGlobalWebChecked = isGlobalWebChecked; + var previousGlobalEmailChecked = isGlobalEmailChecked; + + previousCheckboxStates = { + mainWebChecked: $('.main-checkbox[data-column="web"]') + .map(function () { + return { + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + mainEmailChecked: $('.main-checkbox[data-column="email"]') + .map(function () { + return { + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + webChecked: $(".web-checkbox") + .map(function () { + return { + id: $(this).data("pk"), + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + emailChecked: $(".email-checkbox") + .map(function () { + return { + id: $(this).data("pk"), + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + }; + + // Update the state based on the selected option + if (dropdownType === "web") { + isGlobalWebChecked = selectedOptionText === "Notify on Web"; + } else if (dropdownType === "email") { + isGlobalEmailChecked = selectedOptionText === "Notify by Email"; + } - let isGlobalWebChecked = $('#global-web').is(':checked'); - let isGlobalEmailChecked = $('#global-email').is(':checked'); + // Email notifications require web notifications to be enabled + if (triggeredBy === "email" && isGlobalEmailChecked) { + isGlobalWebChecked = true; + } - // Store previous states for potential rollback - let previousGlobalWebChecked, previousGlobalEmailChecked; - if (triggeredBy === 'global-email') { - previousGlobalEmailChecked = !isGlobalEmailChecked; - previousGlobalWebChecked = isGlobalWebChecked; - } else { - previousGlobalWebChecked = !isGlobalWebChecked; - previousGlobalEmailChecked = isGlobalEmailChecked; + // Disabling web notifications also disables email notifications + if (triggeredBy === "web" && !isGlobalWebChecked) { + isGlobalEmailChecked = false; + } + + isUpdateInProgress = true; + + // Update the UI and data-state attributes + $webToggle + .html( + (isGlobalWebChecked ? "Notify on Web" : "Don't Notify on Web") + + " " + + createArrowSpanHtml() + ) + .attr("data-state", isGlobalWebChecked ? "on" : "off"); + $webDropdown.attr("data-web-state", isGlobalWebChecked ? "Yes" : "No"); + + $emailToggle + .html( + (isGlobalEmailChecked ? "Notify by Email" : "Don't Notify by Email") + + " " + + createArrowSpanHtml() + ) + .attr("data-state", isGlobalEmailChecked ? "on" : "off"); + $emailDropdown.attr("data-email-state", isGlobalEmailChecked ? "Yes" : "No"); + + // Update the checkboxes + $('.main-checkbox[data-column="web"]') + .prop("checked", isGlobalWebChecked) + .change(); + $(".web-checkbox").prop("checked", isGlobalWebChecked); + if ( + (dropdownType === "web" && !isGlobalWebChecked) || + dropdownType === "email" + ) { + $(".email-checkbox").prop("checked", isGlobalEmailChecked); + $('.main-checkbox[data-column="email"]') + .prop("checked", isGlobalEmailChecked) + .change(); + } + + var data = JSON.stringify({ + web: isGlobalWebChecked, + email: isGlobalEmailChecked, + }); + + $('.module').each(function () { + const organizationId = $(this).find('.main-checkbox').data('organization-id'); + updateOrgLevelCheckboxes(organizationId); + }); + + $.ajax({ + type: "PATCH", + url: getAbsoluteUrl( + `/api/v1/notifications/user/${userId}/user-setting/${globalSettingId}/` + ), + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + contentType: "application/json", + data: data, + success: function () { + showToast( + "success", + gettext("Global settings updated successfully.") + ); + }, + error: function () { + showToast( + "error", + gettext("Something went wrong. Please try again.") + ); + + // Rollback the UI changes + isGlobalWebChecked = previousGlobalWebChecked; + isGlobalEmailChecked = previousGlobalEmailChecked; + + // Update the dropdown toggles and data-state attributes + $webToggle + .html( + (isGlobalWebChecked ? "Notify on Web" : "Don't Notify on Web") + + " " + + createArrowSpanHtml() + ) + .attr("data-state", isGlobalWebChecked ? "on" : "off"); + $webDropdown.attr("data-web-state", isGlobalWebChecked ? "Yes" : "No"); + + $emailToggle + .html( + (isGlobalEmailChecked ? "Notify by Email" : "Don't Notify by Email") + + " " + + createArrowSpanHtml() + ) + .attr("data-state", isGlobalEmailChecked ? "on" : "off"); + $emailDropdown.attr("data-email-state", isGlobalEmailChecked ? "Yes" : "No"); + + // Restore the checkboxes + previousCheckboxStates.mainWebChecked.forEach(function (item) { + $( + `.main-checkbox[data-organization-id="${item.orgId}"][data-column="web"]` + ).prop("checked", item.checked); + }); + previousCheckboxStates.mainEmailChecked.forEach(function (item) { + $( + `.main-checkbox[data-organization-id="${item.orgId}"][data-column="email"]` + ).prop("checked", item.checked); + }); + previousCheckboxStates.webChecked.forEach(function (item) { + $( + `.web-checkbox[data-organization-id="${item.orgId}"][data-pk="${item.id}"]` + ).prop("checked", item.checked); + }); + previousCheckboxStates.emailChecked.forEach(function (item) { + $( + `.email-checkbox[data-organization-id="${item.orgId}"][data-pk="${item.id}"]` + ).prop("checked", item.checked); + }); + + $('.module').each(function () { + const organizationId = $(this).find('.main-checkbox').data('organization-id'); + updateOrgLevelCheckboxes(organizationId); + }); + }, + complete: function () { + isUpdateInProgress = false; + }, + }); } + $modal.hide(); + }); - const previousCheckboxStates = { - mainWebChecked: $('.main-checkbox[data-column="web"]').map(function() { - return { orgId: $(this).data('organization-id'), checked: $(this).is(':checked') }; - }).get(), - mainEmailChecked: $('.main-checkbox[data-column="email"]').map(function() { - return { orgId: $(this).data('organization-id'), checked: $(this).is(':checked') }; - }).get(), - webChecked: $('.web-checkbox').map(function() { - return { id: $(this).data('pk'), orgId: $(this).data('organization-id'), checked: $(this).is(':checked') }; - }).get(), - emailChecked: $('.email-checkbox').map(function() { - return { id: $(this).data('pk'), orgId: $(this).data('organization-id'), checked: $(this).is(':checked') }; - }).get() - }; + // Update modal content dynamically + function updateModalContent() { + var $modalIcon = $modal.find('.modal-icon'); + var $modalHeader = $modal.find('.modal-header h2'); + var $modalMessage = $modal.find('.modal-message'); - // Email notifications require web notifications to be enabled - if (triggeredBy === 'global-email' && isGlobalEmailChecked) { - isGlobalWebChecked = true; + // Clear previous icon + $modalIcon.empty(); + + var dropdownType = activeDropdown.is("[data-web-state]") ? "web" : "email"; + + var newGlobalWebChecked = selectedOptionText === "Notify on Web"; + var newGlobalEmailChecked = selectedOptionText === "Notify by Email"; + + // Enabling email notifications requires web notifications to be enabled + if (newGlobalEmailChecked && !newGlobalWebChecked) { + newGlobalWebChecked = true; } // Disabling web notifications also disables email notifications - if (triggeredBy === 'global-web' && !isGlobalWebChecked) { - isGlobalEmailChecked = false; + if (!newGlobalWebChecked) { + newGlobalEmailChecked = false; } - isUpdateInProgress = true; - - // Update the UI - $('#global-web').prop('checked', isGlobalWebChecked); - $('#global-email').prop('checked', isGlobalEmailChecked); - - $('.main-checkbox[data-column="web"]').prop('checked', isGlobalWebChecked).change(); - $('.web-checkbox').prop('checked', isGlobalWebChecked); - if ((triggeredBy === 'global-web' && !isGlobalWebChecked) || triggeredBy === 'global-email') { - $('.email-checkbox').prop('checked', isGlobalEmailChecked); - $('.main-checkbox[data-column="email"]').prop('checked', isGlobalEmailChecked).change(); + // Message to show the settings that will be updated + var changes = []; + + // Case 1: Enabling global web notifications, email remains the same + var isOnlyEnablingWeb = + newGlobalWebChecked === true && + dropdownType === "web"; + + // Case 2: Disabling global email notifications, web remains the same + var isOnlyDisablingEmail = + newGlobalEmailChecked === false && + dropdownType === "email"; + + if (isOnlyEnablingWeb) { + // Only web notification is being enabled + changes.push('Web notifications will be enabled.'); + } else if (isOnlyDisablingEmail) { + // Only email notification is being disabled + changes.push('Email notifications will be disabled.'); + } else { + // For all other cases, display both settings + changes.push('Web notifications will be ' + (newGlobalWebChecked ? 'enabled' : 'disabled') + '.'); + changes.push('Email notifications will be ' + (newGlobalEmailChecked ? 'enabled' : 'disabled') + '.'); } - $.ajax({ - type: 'PATCH', - url: getAbsoluteUrl(`/api/v1/notifications/user/${userId}/user-setting/${globalSettingId}/`), - headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }, - contentType: 'application/json', - data: JSON.stringify({ web: isGlobalWebChecked, email: isGlobalEmailChecked }), - success: function () { - showToast('success', gettext('Global settings updated successfully.')); - }, - error: function () { - showToast('error', gettext('Something went wrong. Please try again.')); + // Set the modal icon + if (dropdownType === "web") { + $modalIcon.html('
'); + } else if (dropdownType === "email") { + $modalIcon.html('
'); + } - $('#global-web').prop('checked', previousGlobalWebChecked); - $('#global-email').prop('checked', previousGlobalEmailChecked); + // Update the modal header text + if (dropdownType === "web") { + $modalHeader.text('Apply Global Setting for Web'); + } else if (dropdownType === "email") { + $modalHeader.text('Apply Global Setting for Email'); + } - previousCheckboxStates.mainWebChecked.forEach(function(item) { - $(`.main-checkbox[data-organization-id="${item.orgId}"][data-column="web"]`).prop('checked', item.checked); - }); - previousCheckboxStates.mainEmailChecked.forEach(function(item) { - $(`.main-checkbox[data-organization-id="${item.orgId}"][data-column="email"]`).prop('checked', item.checked); - }); - previousCheckboxStates.webChecked.forEach(function(item) { - $(`.web-checkbox[data-organization-id="${item.orgId}"][data-pk="${item.id}"]`).prop('checked', item.checked); - }); - previousCheckboxStates.emailChecked.forEach(function(item) { - $(`.email-checkbox[data-organization-id="${item.orgId}"][data-pk="${item.id}"]`).prop('checked', item.checked); - }); - }, - complete: function () { - isUpdateInProgress = false; - } - }); - }); + // Update the modal message + var message = 'The following settings will be applied:
' + changes.join('
') + '
Do you want to continue?'; + $modalMessage.html(message); + } } function showToast(level, message) { @@ -472,7 +762,7 @@ function getAbsoluteUrl(url) { const toast = document.createElement('div'); toast.className = `toast ${level}`; toast.innerHTML = ` -
+
${message}
@@ -506,4 +796,8 @@ function getAbsoluteUrl(url) { } }); } + + function createArrowSpanHtml() { + return ''; + } })(django.jQuery); diff --git a/openwisp_notifications/templates/openwisp_notifications/preferences.html b/openwisp_notifications/templates/openwisp_notifications/preferences.html index f201d197..4170f4eb 100644 --- a/openwisp_notifications/templates/openwisp_notifications/preferences.html +++ b/openwisp_notifications/templates/openwisp_notifications/preferences.html @@ -24,31 +24,66 @@
{% endblock breadcrumbs %} - {% block content %}
- {% trans "Global Settings:" %} -
- {% trans "Web" %} - - ? -
+

Global Settings

- {% trans "Email" %} - - ? + +
+
+
+
+

Web

+

Enable or Disable all web notifications globally

+
+ +
    +
  • Notify on Web
  • +
  • Don't Notify on Web
  • +
+
+
+
+
+
+ +
+
+
+
+

Email

+

Enable or Disable all email notifications globally

+
+ +
    +
  • Notify by Email
  • +
  • Don't Notify by Email
  • +
+
+
+
+
+ + + {% endblock content %} {% block footer %} diff --git a/openwisp_notifications/tests/test_selenium.py b/openwisp_notifications/tests/test_selenium.py index 8d0d2548..fa2cf330 100644 --- a/openwisp_notifications/tests/test_selenium.py +++ b/openwisp_notifications/tests/test_selenium.py @@ -100,13 +100,43 @@ def test_notification_preference_page(self): self.login() self.open('/notifications/preferences/') + WebDriverWait(self.web_driver, 30).until( + EC.visibility_of_element_located( + (By.CLASS_NAME, 'global-settings-container') + ) + ) + # Uncheck the global web checkbox - global_web_label = WebDriverWait(self.web_driver, 30).until( + global_web_dropdown_toggle = WebDriverWait(self.web_driver, 30).until( EC.element_to_be_clickable( - (By.XPATH, "//*[@id='global-web']/parent::label") + ( + By.CSS_SELECTOR, + '.global-setting-dropdown[data-web-state] .global-setting-dropdown-toggle', + ) ) ) - global_web_label.click() + global_web_dropdown_toggle.click() + + global_web_dropdown_menu = WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located( + ( + By.CSS_SELECTOR, + '.global-setting-dropdown[data-web-state] .global-setting-dropdown-menu-open', + ) + ) + ) + + dont_notify_on_web_option = global_web_dropdown_menu.find_element( + By.XPATH, './/li[normalize-space()="Don\'t Notify on Web"]' + ) + dont_notify_on_web_option.click() + + confirmation_modal = WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, 'confirmation-modal')) + ) + + confirm_button = confirmation_modal.find_element(By.ID, 'confirm') + confirm_button.click() all_checkboxes = self.web_driver.find_elements( By.CSS_SELECTOR, 'input[type="checkbox"]' @@ -114,12 +144,19 @@ def test_notification_preference_page(self): for checkbox in all_checkboxes: self.assertFalse(checkbox.is_selected()) + # Expand the first organization panel if it's collapsed + first_org_toggle = WebDriverWait(self.web_driver, 10).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, '.module .toggle-header')) + ) + first_org_toggle.click() + # Check the org-level web checkbox org_level_web_checkbox = WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.ID, 'org-1-web')) + EC.element_to_be_clickable((By.ID, 'org-1-web')) ) org_level_web_checkbox.click() + # Verify that all web checkboxes under org-1 are selected web_checkboxes = self.web_driver.find_elements( By.CSS_SELECTOR, 'input[id^="org-1-web-"]' ) @@ -142,7 +179,7 @@ def test_empty_notification_preference_page(self): self.login() self.open('/notifications/preferences/') - no_organizations_element = WebDriverWait(self.web_driver, 10).until( + no_organizations_element = WebDriverWait(self.web_driver, 30).until( EC.visibility_of_element_located((By.CLASS_NAME, 'no-organizations')) ) self.assertEqual(