diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51b4f151..3bc30c93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,7 +58,7 @@ jobs: pip install -U -r requirements-test.txt pip install -U -e . pip install ${{ matrix.django-version }} - sudo npm install -g jshint stylelint + sudo npm install -g prettier - name: QA checks run: | diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..8b133377 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,15 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + trailingComma: "es5", + tabWidth: 2, + semi: true, + singleQuote: false, + arrowParens: "always", + printWidth: 80, // Adjusting print width can help manage line breaks + experimentalTernaries: true, // Use experimental ternary formatting +}; + +module.exports = config; diff --git a/docs/user/notification-preferences.rst b/docs/user/notification-preferences.rst index 1e2dfa8d..dd1713be 100644 --- a/docs/user/notification-preferences.rst +++ b/docs/user/notification-preferences.rst @@ -1,8 +1,8 @@ Notification Preferences ======================== -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/notification-settings.png - :target: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/notification-settings.png +.. image:: https://i.imgur.com/lIGqry5.png + :target: https://i.imgur.com/lIGqry5.png :align: center OpenWISP Notifications enables users to customize their notification @@ -12,6 +12,10 @@ organized by notification type and organization, allowing users to tailor their notification experience by opting to receive updates only from specific organizations or notification types. +Users can access and manage their notification preferences by visiting the +``/notification/preferences/``. Alternatively, this page can also be +accessed directly from the notification widget. + Notification settings are automatically generated for all notification types and organizations for every user. Superusers have the ability to manage notification settings for all users, including adding or deleting diff --git a/openwisp_notifications/admin.py b/openwisp_notifications/admin.py index 0e320be6..92ca3c2b 100644 --- a/openwisp_notifications/admin.py +++ b/openwisp_notifications/admin.py @@ -1,25 +1,3 @@ -from django.contrib import admin - -from openwisp_notifications.base.admin import NotificationSettingAdminMixin -from openwisp_notifications.swapper import load_model from openwisp_notifications.widgets import _add_object_notification_widget -from openwisp_users.admin import UserAdmin -from openwisp_utils.admin import AlwaysHasChangedMixin - -Notification = load_model('Notification') -NotificationSetting = load_model('NotificationSetting') - - -class NotificationSettingInline( - NotificationSettingAdminMixin, AlwaysHasChangedMixin, admin.TabularInline -): - model = NotificationSetting - extra = 0 - - def has_change_permission(self, request, obj=None): - return request.user.is_superuser or request.user == obj - - -UserAdmin.inlines = [NotificationSettingInline] + UserAdmin.inlines _add_object_notification_widget() diff --git a/openwisp_notifications/api/permissions.py b/openwisp_notifications/api/permissions.py new file mode 100644 index 00000000..e2aecf07 --- /dev/null +++ b/openwisp_notifications/api/permissions.py @@ -0,0 +1,16 @@ +from rest_framework.permissions import BasePermission + + +class PreferencesPermission(BasePermission): + """ + Permission class for the notification preferences. + + Permission is granted only in these two cases: + 1. Superusers can change the notification preferences of any user. + 2. Regular users can only change their own preferences. + """ + + def has_permission(self, request, view): + return request.user.is_superuser or request.user.id == view.kwargs.get( + 'user_id' + ) diff --git a/openwisp_notifications/api/serializers.py b/openwisp_notifications/api/serializers.py index d4dbd41d..676059d0 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.CharField( + source='organization.name', read_only=True + ) + type_label = serializers.CharField(source='get_type_display', read_only=True) + class Meta: model = NotificationSetting exclude = ['user'] @@ -87,3 +92,14 @@ class Meta: 'object_content_type', 'object_id', ] + + +class NotificationSettingUpdateSerializer(serializers.Serializer): + 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/urls.py b/openwisp_notifications/api/urls.py index 597d2a74..5550fc39 100644 --- a/openwisp_notifications/api/urls.py +++ b/openwisp_notifications/api/urls.py @@ -9,32 +9,56 @@ 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/', + 'notification/read/', + views.notifications_read_all, + name='notifications_read_all', + ), + path( + 'notification//', + views.notification_detail, + name='notification_detail', + ), + path( + 'notification//redirect/', views.notification_read_redirect, name='notification_read_redirect', ), path( - 'user-setting/', + 'user//user-setting/', views.notification_setting_list, - name='notification_setting_list', + name='user_notification_setting_list', ), path( - 'user-setting//', + 'user//user-setting//', views.notification_setting, - name='notification_setting', + name='user_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', ), + path( + 'user//organization//setting/', + views.organization_notification_setting, + name='organization_notification_setting', + ), + # 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 7320c72e..2c60a925 100644 --- a/openwisp_notifications/api/views.py +++ b/openwisp_notifications/api/views.py @@ -15,11 +15,13 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from openwisp_notifications.api.permissions import PreferencesPermission from openwisp_notifications.api.serializers import ( IgnoreObjectNotificationSerializer, NotificationListSerializer, NotificationSerializer, NotificationSettingSerializer, + NotificationSettingUpdateSerializer, ) from openwisp_notifications.swapper import load_model from openwisp_users.api.authentication import BearerAuthentication @@ -114,12 +116,13 @@ class BaseNotificationSettingView(GenericAPIView): model = NotificationSetting serializer_class = NotificationSettingSerializer authentication_classes = [BearerAuthentication, SessionAuthentication] - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, PreferencesPermission] def get_queryset(self): if getattr(self, 'swagger_fake_view', False): return NotificationSetting.objects.none() # pragma: no cover - return NotificationSetting.objects.filter(user=self.request.user) + user_id = self.kwargs.get('user_id', self.request.user.id) + return NotificationSetting.objects.filter(user_id=user_id) class NotificationSettingListView(BaseNotificationSettingView, ListModelMixin): @@ -198,11 +201,27 @@ def perform_create(self, serializer): ) +class OrganizationNotificationSettingView(GenericAPIView): + permission_classes = [IsAuthenticated, PreferencesPermission] + serializer_class = NotificationSettingUpdateSerializer + + def post(self, request, user_id, organization_id): + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + validated_data = serializer.validated_data + 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) + + 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() +organization_notification_setting = OrganizationNotificationSettingView.as_view() ignore_object_notification_list = IgnoreObjectNotificationListView.as_view() ignore_object_notification = IgnoreObjectNotificationView.as_view() diff --git a/openwisp_notifications/base/admin.py b/openwisp_notifications/base/admin.py index 003a814f..3ac537ce 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') ) @@ -33,5 +34,4 @@ class Media: extends = True js = [ 'admin/js/jquery.init.js', - 'openwisp-notifications/js/notification-settings.js', ] diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index f4e2fd55..29e33778 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -6,7 +6,8 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site from django.core.cache import cache -from django.db import models +from django.core.exceptions import ValidationError +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 @@ -246,12 +247,15 @@ class AbstractNotificationSetting(UUIDModel): type = models.CharField( max_length=30, null=True, + blank=True, choices=NOTIFICATION_CHOICES, verbose_name='Notification Type', ) 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) @@ -277,21 +281,64 @@ class Meta: ] def __str__(self): - return '{type} - {organization}'.format( - type=self.type_config['verbose_name'], - organization=self.organization, - ) + type_name = self.type_config.get('verbose_name', 'Global Setting') + if self.organization: + return '{type} - {organization}'.format( + type=type_name, + organization=self.organization, + ) + else: + return type_name + + def validate_global_setting(self): + if self.organization is None and self.type is None: + if ( + self.__class__.objects.filter( + user=self.user, + organization=None, + type=None, + ) + .exclude(pk=self.pk) + .exists() + ): + raise ValidationError("There can only be one global setting per user.") def save(self, *args, **kwargs): if not self.web_notification: self.email = self.web_notification + with transaction.atomic(): + if not self.organization and not self.type: + try: + previous_state = self.__class__.objects.only('email').get( + pk=self.pk + ) + updates = {'web': self.web} + + # 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( + **updates + ) + 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/handlers.py b/openwisp_notifications/handlers.py index 67da6898..f247e67a 100644 --- a/openwisp_notifications/handlers.py +++ b/openwisp_notifications/handlers.py @@ -170,6 +170,7 @@ 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) + if instance.type and target_org: try: notification_setting = instance.recipient.notificationsetting_set.get( diff --git a/openwisp_notifications/migrations/0008_alter_notificationsetting_organization_and_more.py b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization_and_more.py new file mode 100644 index 00000000..95a1e4ee --- /dev/null +++ b/openwisp_notifications/migrations/0008_alter_notificationsetting_organization_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.16 on 2024-09-17 13:19 + +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", + ), + ), + 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/loader.css b/openwisp_notifications/static/openwisp-notifications/css/loader.css index 74298297..280dc531 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/loader.css +++ b/openwisp_notifications/static/openwisp-notifications/css/loader.css @@ -8,7 +8,11 @@ border-radius: 50%; background: #464646; background: -moz-linear-gradient(left, #464646 10%, rgba(70, 70, 70, 0) 42%); - background: -webkit-linear-gradient(left, #464646 10%, rgba(70, 70, 70, 0) 42%); + background: -webkit-linear-gradient( + left, + #464646 10%, + rgba(70, 70, 70, 0) 42% + ); background: -o-linear-gradient(left, #464646 10%, rgba(70, 70, 70, 0) 42%); background: -ms-linear-gradient(left, #464646 10%, rgba(70, 70, 70, 0) 42%); background: linear-gradient(to right, #464646 10%, rgba(70, 70, 70, 0) 42%); @@ -27,14 +31,14 @@ position: absolute; top: 0; left: 0; - content: ''; + content: ""; } .loader:after { background: #ffffff; width: 75%; height: 75%; border-radius: 50%; - content: ''; + content: ""; margin: auto; position: absolute; top: 0; diff --git a/openwisp_notifications/static/openwisp-notifications/css/notifications.css b/openwisp_notifications/static/openwisp-notifications/css/notifications.css index a8b27932..6cb229bf 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/notifications.css +++ b/openwisp_notifications/static/openwisp-notifications/css/notifications.css @@ -1,5 +1,5 @@ .ow-hide { - display: none!important; + display: none !important; } .ow-round-bottom-border { border-bottom-left-radius: 3px; @@ -24,7 +24,8 @@ border: 0px; background: #777; } -.ow-notifications.toggle-btn:hover #ow-notification-btn,.ow-notifications:focus #ow-notification-btn{ +.ow-notifications.toggle-btn:hover #ow-notification-btn, +.ow-notifications:focus #ow-notification-btn { background: #df5d43; } #ow-notification-count { @@ -113,7 +114,7 @@ float: right; position: relative; right: -2px; - bottom: -8px; + bottom: -3px; background-size: 9px; } .ow-notification-toast.info .icon { @@ -121,7 +122,7 @@ } .ow-notification-toast.warning .icon { filter: invert(20%) sepia(90%) saturate(5000%) hue-rotate(366deg) - brightness(92%) contrast(96%); + brightness(92%) contrast(96%); } .ow-notification-toast.error .icon, .ow-notification-toast.success .icon { @@ -144,7 +145,8 @@ top: 49px; } .ow-notification-dropdown .toggle-btn { - color: #777; + color: #777 !important; + text-decoration: none !important; } .ow-notification-dropdown .toggle-btn:active { position: relative; @@ -226,12 +228,12 @@ .ow-notification-dropdown .toggle-btn:focus { outline: 0.5px dotted rgba(0, 0, 0, 0.25); } -#ow-notification-dropdown-error-container{ +#ow-notification-dropdown-error-container { color: #c51b25; - background-color: #FFD2D2; + background-color: #ffd2d2; display: none; } -#ow-notification-dropdown-error{ +#ow-notification-dropdown-error { margin: auto; width: 90%; text-align: center; @@ -265,41 +267,43 @@ min-width: 15px; background-repeat: no-repeat; filter: invert(46%) sepia(4%) saturate(139%) hue-rotate(317deg) - brightness(99%) contrast(85%); + brightness(99%) contrast(85%); margin: -1px 1px 0 0; } .ow-notification-elem:hover .icon { filter: invert(20%) sepia(1%) saturate(139%) hue-rotate(317deg) - brightness(99%) contrast(85%); + brightness(99%) contrast(85%); } .ow-notification-elem.unread .icon { filter: invert(95%) sepia(1%) saturate(139%) hue-rotate(317deg) - brightness(99%) contrast(85%); + brightness(99%) contrast(85%); } .ow-notification-elem.unread:hover .icon { filter: invert(100%) sepia(0%) saturate(139%) hue-rotate(317deg) - brightness(100%) contrast(85%); + brightness(100%) contrast(85%); } .ow-notify-info { - background-image: url('../../openwisp-notifications/images/icons/icon-info.svg'); + background-image: url("../../openwisp-notifications/images/icons/icon-info.svg"); } .ow-notify-warning { - background-image: url('../../openwisp-notifications/images/icons/icon-warning.svg'); + background-image: url("../../openwisp-notifications/images/icons/icon-warning.svg"); } .ow-notify-error { - background-image: url('../../openwisp-notifications/images/icons/icon-error.svg'); + background-image: url("../../openwisp-notifications/images/icons/icon-error.svg"); } .ow-notify-success { - background-image: url('../../openwisp-notifications/images/icons/icon-success.svg'); + background-image: url("../../openwisp-notifications/images/icons/icon-success.svg"); } .ow-notify-close { - background-image: url('../../openwisp-notifications/images/icons/icon-close.svg'); + background-image: url("../../openwisp-notifications/images/icons/icon-close.svg"); } .ow-notification-inner { border-bottom: 1px solid rgba(0, 0, 0, 0.1); padding: 16px 22px; } -.ow-notification-elem:last-child .ow-notification-inner { border-bottom: none } +.ow-notification-elem:last-child .ow-notification-inner { + border-bottom: none; +} /* Generic notification dialog */ .ow-overlay-notification { diff --git a/openwisp_notifications/static/openwisp-notifications/css/object-notifications.css b/openwisp_notifications/static/openwisp-notifications/css/object-notifications.css index 7adf898d..4bd864cb 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/object-notifications.css +++ b/openwisp_notifications/static/openwisp-notifications/css/object-notifications.css @@ -35,12 +35,12 @@ /* Icons */ .ow-object-notify-bell { - mask-image: url('../../openwisp-notifications/images/icons/icon-bell.svg'); - -webkit-mask-image: url('../../openwisp-notifications/images/icons/icon-bell.svg'); + mask-image: url("../../openwisp-notifications/images/icons/icon-bell.svg"); + -webkit-mask-image: url("../../openwisp-notifications/images/icons/icon-bell.svg"); } .ow-object-notify-slash-bell { - mask-image: url('../../openwisp-notifications/images/icons/icon-bell-slash.svg'); - -webkit-mask-image: url('../../openwisp-notifications/images/icons/icon-bell-slash.svg'); + mask-image: url("../../openwisp-notifications/images/icons/icon-bell-slash.svg"); + -webkit-mask-image: url("../../openwisp-notifications/images/icons/icon-bell-slash.svg"); } #ow-object-notify .ow-icon { display: inline-block; @@ -55,7 +55,7 @@ mask-repeat: no-repeat; margin-right: 2px; } -#ow-object-notify span{ +#ow-object-notify span { pointer-events: none; } @media screen and (max-width: 768px) { diff --git a/openwisp_notifications/static/openwisp-notifications/css/preferences.css b/openwisp_notifications/static/openwisp-notifications/css/preferences.css new file mode 100644 index 00000000..66124b95 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/css/preferences.css @@ -0,0 +1,384 @@ +/* Global Settings */ +.global-settings { + margin: 0 0 30px; + display: none; +} +.global-settings > h2 { + font-size: 1.1rem; + margin-top: 0.5rem; + font-weight: normal; +} +.global-settings-container { + display: flex; + border: 1px solid var(--hairline-color); + border-radius: 4px; +} +.global-setting { + flex: 1; + padding: 20px; +} +.global-setting-text h2 { + margin: 0 0 5px 0; +} +.global-setting-content { + display: flex; + margin-bottom: 10px; +} +.global-setting-content h2 { + color: var(--body-fg); +} + +/* Dropdown */ +.global-setting-dropdown { + position: relative; +} +.global-setting-dropdown button { + color: #777; + font-weight: 700; + font-family: var(--font-family-primary); + position: relative; + padding-right: 35px; +} +.global-setting-dropdown button:hover, +.global-setting-dropdown button:focus { + color: #df5d43; +} +.global-setting-dropdown-toggle { + display: flex; + padding: 10px 16px; + background-color: inherit; + border: 1px solid var(--hairline-color); + font-size: 0.9em; + border-radius: 4px; + cursor: pointer; + box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); +} +.global-setting-dropdown-toggle:hover .mg-arrow, +.global-setting-dropdown-toggle:active .mg-arrow { + background-color: #df5d43; +} +.global-setting-dropdown-toggle .mg-arrow { + background-color: #777; + display: block; + position: absolute; + top: 7px; + right: 10px; +} +.global-setting-dropdown-menu { + z-index: 1; + display: none; + position: absolute; + background-color: #fff; + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 0; + margin: 0; +} +.global-setting-dropdown-menu-open { + display: block; + margin-top: -1px; +} +.global-setting-dropdown-menu button { + width: 100%; + padding: 11px 15px; + cursor: pointer; + border: none; + background: inherit; + text-align: left; + display: block; +} +.global-setting-dropdown-menu button:not(:last-child) { + border-bottom: 1px solid var(--hairline-color); +} + +/* Icons */ +.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; +} + +/* Modal */ +.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; +} +.modal ul { + margin-bottom: 0.2em; +} +#go-back, +#confirm { + width: 100%; +} + +/* Module */ +.module h2 { + cursor: pointer; + font-weight: bold; + font-size: 14px; + text-transform: uppercase; + padding: 0; +} + +/* Organization */ +.type-col-header { + width: 40%; +} +.org-name { + width: 40%; + text-align: left; +} +.email-row { + position: relative; +} +.org-content { + padding-top: 0; + display: none; +} +table { + width: 100%; +} + +thead.toggle-header, +table tr.org-header, +#org-panels table tbody tr:nth-child(odd) { + background: var(--darkened-bg); +} +#org-panels table thead th, +#org-panels table tbody td { + padding: 15px !important; + font-size: 14px !important; + vertical-align: middle !important; +} +#org-panels th, +#org-panels td { + border: 1px solid var(--hairline-color); +} +#org-panels table tbody tr:nth-child(even) { + background-color: var(--body-bg); +} +tr.org-header { + text-transform: uppercase; + color: var(--body-quiet-color); + font-weight: 700; +} +#org-panels th:not(:first-child) h2, +#org-panels td:not(:first-child) { + text-align: center; +} +.no-settings, +.no-organizations { + padding: 10px; + text-align: center; + color: #666; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 20px; + right: 20px; + background-color: #333; + color: white; + padding: 12px 20px; + border-radius: 5px; + transition: opacity 0.5s ease-in-out; + z-index: 9999; + cursor: pointer; +} +.toast .icon { + background-repeat: no-repeat; + margin-right: 0.3rem; +} +.toast { + padding-bottom: 15px; +} +.toast .progress-bar { + position: absolute; + bottom: 0; + left: 0; + height: 4px; + background-color: #007bff; + width: 100%; + transition: width 3s linear; +} + +.ow-notify-success { + filter: invert(48%) sepia(98%) saturate(546%) hue-rotate(95deg) + brightness(95%) contrast(90%); +} +.ow-notify-error { + filter: invert(18%) sepia(99%) saturate(5461%) hue-rotate(-10deg) + brightness(85%) contrast(120%); +} + +/* Toggle Icon */ +button.toggle-icon { + position: absolute; + right: 15px; + top: 15px; + width: 20px; + height: 16px; + margin-right: 5px; + background: url(/static/admin/img/sorting-icons.svg) 0 0 no-repeat; + background-size: 20px auto; + border: none; +} +button.toggle-icon.collapsed { + background-position: 0px -84px; +} +button.toggle-icon.expanded { + background-position: 0px -44px; + filter: grayscale(100%) brightness(40%); +} + +/* Tooltip */ +.tooltip-icon { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + color: var(--body-quiet-color); + text-align: center; + line-height: 14px; + font-size: 12px; + font-weight: bold; + position: relative; + border: 1px solid var(--body-quiet-color); + text-transform: none; + cursor: default; +} +.tooltip-icon::after { + content: attr(data-tooltip); + position: absolute; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fff; + padding: 5px 10px; + border-radius: 2px; + font-size: 10px; + white-space: nowrap; + visibility: hidden; +} +.tooltip-icon:hover::after { + visibility: visible; +} + +/* Switch */ +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; +} +.switch input { + opacity: 0; + width: 0; + height: 0; +} +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: 0.4s; + transition: 0.4s; +} +.slider:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background-color: white; + -webkit-transition: 0.4s; + transition: 0.4s; +} +input:checked + .slider { + background-color: #2196f3; +} +input:focus + .slider { + box-shadow: 0 0 5px 2px #2196f3; + outline: none; +} +input:checked + .slider:before { + -webkit-transform: translateX(20px); + -ms-transform: translateX(20px); + transform: translateX(20px); +} +.slider.round { + border-radius: 20px; +} +.slider.round:before { + border-radius: 50%; +} + +/* Notification Headers */ +.notification-web-header, +.notification-email-header { + text-align: center; +} +.notification-header-container { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +/* For readability on narrow screens */ +.settings-container { + min-width: 520px; +} + +/* Media Queries */ +@media screen and (min-width: 600px) { + .global-setting + .global-setting { + border-left: 1px solid var(--hairline-color); + } +} +@media screen and (max-width: 600px) { + .global-setting + .global-setting { + border-top: 1px solid var(--hairline-color); + } + .global-settings-container { + flex-direction: column; + } +} 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..6429f51a --- /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..e7d5ff4b --- /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/notification-settings.js b/openwisp_notifications/static/openwisp-notifications/js/notification-settings.js deleted file mode 100644 index 6c4390b4..00000000 --- a/openwisp_notifications/static/openwisp-notifications/js/notification-settings.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; -(function ($) { - $(document).ready(function () { - let emailCheckboxSelector = '.dynamic-notificationsetting_set .field-email > input[type="checkbox"]', - webCheckboxSelector = '.dynamic-notificationsetting_set .field-web > input[type="checkbox"]'; - // If email notification is checked, web should also be checked. - $(document).on('change', emailCheckboxSelector, function(){ - let emailCheckBoxId = $(this).attr('id'), - webCheckboxId = emailCheckBoxId.replace('-email', '-web'); - if($(this).prop('checked') == true){ - $(`#${webCheckboxId}`).prop('checked', $(this).prop('checked')); - } - }); - // If web notification is unchecked, email should also be unchecked. - $(document).on('change', webCheckboxSelector, function(){ - let webCheckboxId = $(this).attr('id'), - emailCheckBoxId = webCheckboxId.replace('-web', '-email'); - if($(this).prop('checked') == false){ - $(`#${emailCheckBoxId}`).prop('checked', $(this).prop('checked')); - } - }); - }); -})(django.jQuery); diff --git a/openwisp_notifications/static/openwisp-notifications/js/notifications.js b/openwisp_notifications/static/openwisp-notifications/js/notifications.js index 65bd7401..8f076cc2 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/notifications.js +++ b/openwisp_notifications/static/openwisp-notifications/js/notifications.js @@ -1,258 +1,269 @@ -'use strict'; +"use strict"; const notificationReadStatus = new Map(); const userLanguage = navigator.language || navigator.userLanguage; const owWindowId = String(Date.now()); let fetchedPages = []; -if (typeof gettext === 'undefined') { - var gettext = function(word){ return word; }; +if (typeof gettext === "undefined") { + var gettext = function (word) { + return word; + }; } (function ($) { - $(document).ready(function () { - notificationWidget($); - initNotificationDropDown($); - initWebSockets($); - owNotificationWindow.init($); - }); + $(document).ready(function () { + notificationWidget($); + initNotificationDropDown($); + initWebSockets($); + owNotificationWindow.init($); + }); })(django.jQuery); const owNotificationWindow = { - // Following functions are used to decide which window has authority - // to play notification alert sound when multiple windows are open. - init: function init($) { - // Get authority to play notification sound - // when current window is in focus - $(window).on('focus load', function() {owNotificationWindow.set();}); - // Give up the authority to play sound before - // closing the window - $(window).on('beforeunload', function() {owNotificationWindow.remove();}); - // Get authority to play notification sound when - // other windows are closed - $(window).on('storage', function () { - if (localStorage.getItem('owWindowId') === null) { - owNotificationWindow.set(); - } - }); - }, - set: function() { - localStorage.setItem('owWindowId', owWindowId); - }, - remove: function () { - if (localStorage.getItem('owWindowId') === owWindowId) { - localStorage.removeItem('owWindowId'); - } - }, - canPlaySound: function() { - // Returns whether current window has the authority to play - // notification sound - return localStorage.getItem('owWindowId') === owWindowId; - }, -}; - -function initNotificationDropDown($) { - $('.ow-notifications').click(function () { - $('.ow-notification-dropdown').toggleClass('ow-hide'); + // Following functions are used to decide which window has authority + // to play notification alert sound when multiple windows are open. + init: function init($) { + // Get authority to play notification sound + // when current window is in focus + $(window).on("focus load", function () { + owNotificationWindow.set(); }); - - $(document).click(function (e) { - e.stopPropagation(); - if ( - // Check if the clicked area is dropDown - $('.ow-notification-dropdown').has(e.target).length === 0 && - // Check notification-btn or not - !$(e.target).is($('.ow-notifications')) && - // Hide the notification dropdown when a click occurs outside of it - !$(e.target).is($('.ow-dialog-close')) && - // Do not hide if the user is interacting with the notification dialog - !$('.ow-overlay-notification').is(':visible') - ) { - $('.ow-notification-dropdown').addClass('ow-hide'); - } + // Give up the authority to play sound before + // closing the window + $(window).on("beforeunload", function () { + owNotificationWindow.remove(); }); - - // Handler for adding accessibility from keyboard events - $(document).focusin(function(e){ - // Hide notification widget if focus is shifted to an element outside it - e.stopPropagation(); - if ( - $('.ow-notification-dropdown').has(e.target).length === 0 && - // Do not hide if the user is interacting with the notification dialog - !$('.ow-overlay-notification').is(':visible') - ) { - // Don't hide if focus changes to notification bell icon - if (e.target != $('#openwisp_notifications').get(0)) { - $('.ow-notification-dropdown').addClass('ow-hide'); - } - } + // Get authority to play notification sound when + // other windows are closed + $(window).on("storage", function () { + if (localStorage.getItem("owWindowId") === null) { + owNotificationWindow.set(); + } }); + }, + set: function () { + localStorage.setItem("owWindowId", owWindowId); + }, + remove: function () { + if (localStorage.getItem("owWindowId") === owWindowId) { + localStorage.removeItem("owWindowId"); + } + }, + canPlaySound: function () { + // Returns whether current window has the authority to play + // notification sound + return localStorage.getItem("owWindowId") === owWindowId; + }, +}; - $('.ow-notification-dropdown').on('keyup', function(e){ - if (e.keyCode !== 27) { - return; - } - // Hide notification widget on "Escape" key - if ($('.ow-overlay-notification').is(':visible')) { - $('.ow-overlay-notification').addClass('ow-hide'); - $('.ow-message-target-redirect').addClass('ow-hide'); - } else { - $('.ow-notification-dropdown').addClass('ow-hide'); - $('#openwisp_notifications').focus(); - } - }); +function initNotificationDropDown($) { + $(".ow-notifications").click(function () { + $(".ow-notification-dropdown").toggleClass("ow-hide"); + }); + + $(document).click(function (e) { + e.stopPropagation(); + if ( + // Check if the clicked area is dropDown + $(".ow-notification-dropdown").has(e.target).length === 0 && + // Check notification-btn or not + !$(e.target).is($(".ow-notifications")) && + // Hide the notification dropdown when a click occurs outside of it + !$(e.target).is($(".ow-dialog-close")) && + // Do not hide if the user is interacting with the notification dialog + !$(".ow-overlay-notification").is(":visible") + ) { + $(".ow-notification-dropdown").addClass("ow-hide"); + } + }); + + // Handler for adding accessibility from keyboard events + $(document).focusin(function (e) { + // Hide notification widget if focus is shifted to an element outside it + e.stopPropagation(); + if ( + $(".ow-notification-dropdown").has(e.target).length === 0 && + // Do not hide if the user is interacting with the notification dialog + !$(".ow-overlay-notification").is(":visible") + ) { + // Don't hide if focus changes to notification bell icon + if (e.target != $("#openwisp_notifications").get(0)) { + $(".ow-notification-dropdown").addClass("ow-hide"); + } + } + }); - // Show notification widget if URL contains #notifications - if (window.location.hash === '#notifications') { - $('.ow-notification-dropdown').removeClass('ow-hide'); - $('.ow-notification-wrapper').trigger('refreshNotificationWidget'); + $(".ow-notification-dropdown").on("keyup", function (e) { + if (e.keyCode !== 27) { + return; + } + // Hide notification widget on "Escape" key + if ($(".ow-overlay-notification").is(":visible")) { + $(".ow-overlay-notification").addClass("ow-hide"); + $(".ow-message-target-redirect").addClass("ow-hide"); + } else { + $(".ow-notification-dropdown").addClass("ow-hide"); + $("#openwisp_notifications").focus(); } + }); + + // Show notification widget if URL contains #notifications + if (window.location.hash === "#notifications") { + $(".ow-notification-dropdown").removeClass("ow-hide"); + $(".ow-notification-wrapper").trigger("refreshNotificationWidget"); + } } // Used to convert absolute URLs in notification messages to relative paths function convertMessageWithRelativeURL(htmlString) { - const parser = new DOMParser(), - doc = parser.parseFromString(htmlString, 'text/html'), - links = doc.querySelectorAll('a'); - links.forEach((link) => { - let url = link.getAttribute('href'); - if (url) { - url = new URL(url, window.location.href); - link.setAttribute('href', url.pathname); - } - }); - return doc.body.innerHTML; + const parser = new DOMParser(), + doc = parser.parseFromString(htmlString, "text/html"), + links = doc.querySelectorAll("a"); + links.forEach((link) => { + let url = link.getAttribute("href"); + if (url) { + url = new URL(url, window.location.href); + link.setAttribute("href", url.pathname); + } + }); + return doc.body.innerHTML; } function notificationWidget($) { + let nextPageUrl = getAbsoluteUrl("/api/v1/notifications/notification/"), + renderedPages = 2, + busy = false, + lastRenderedPage = 0; + // 1 based indexing (0 -> no page rendered) + + function pageContainer(page) { + var div = $('
'); + page.forEach(function (notification) { + let elem = $(notificationListItem(notification)); + div.append(elem); + }); + return div; + } - let nextPageUrl = getAbsoluteUrl('/api/v1/notifications/notification/'), - renderedPages = 2, - busy = false, - lastRenderedPage = 0; - // 1 based indexing (0 -> no page rendered) - - function pageContainer(page) { - var div = $('
'); - page.forEach(function (notification) { - let elem = $(notificationListItem(notification)); - div.append(elem); - }); - return div; + function appendPage() { + $("#ow-notifications-loader").before( + pageContainer(fetchedPages[lastRenderedPage]) + ); + if (lastRenderedPage >= renderedPages) { + $(".ow-notification-wrapper div:first").remove(); } - - function appendPage() { - $('#ow-notifications-loader').before(pageContainer(fetchedPages[lastRenderedPage])); - if (lastRenderedPage >= renderedPages) { - $('.ow-notification-wrapper div:first').remove(); + lastRenderedPage += 1; + busy = false; + } + + function fetchNextPage() { + $.ajax({ + type: "GET", + url: nextPageUrl, + xhrFields: { + withCredentials: true, + }, + crossDomain: true, + beforeSend: function () { + $(".ow-no-notifications").addClass("ow-hide"); + $("#ow-notifications-loader").removeClass("ow-hide"); + }, + complete: function () { + $("#ow-notifications-loader").addClass("ow-hide"); + }, + success: function (res) { + nextPageUrl = res.next; + if ( + res.count === 0 || + (res.results.length === 0 && nextPageUrl === null) + ) { + // If response does not have any notification, show no-notifications message. + $(".ow-no-notifications").removeClass("ow-hide"); + $("#ow-mark-all-read").addClass("disabled"); + busy = false; + } else { + if (res.results.length === 0 && nextPageUrl !== null) { + fetchNextPage(); + } + fetchedPages.push(res.results); + appendPage(); + // Enable filters + $(".toggle-btn").removeClass("disabled"); } - lastRenderedPage += 1; + }, + error: function (error) { busy = false; + showNotificationDropdownError( + gettext("Failed to fetch notifications. Try again later.") + ); + throw error; + }, + }); + } + + function pageDown() { + busy = true; + if (fetchedPages.length > lastRenderedPage) { + appendPage(); + } else if (nextPageUrl !== null) { + fetchNextPage(); + } else { + busy = false; } - - function fetchNextPage() { - $.ajax({ - type: 'GET', - url: nextPageUrl, - xhrFields: { - withCredentials: true - }, - crossDomain: true, - beforeSend: function(){ - $('.ow-no-notifications').addClass('ow-hide'); - $('#ow-notifications-loader').removeClass('ow-hide'); - }, - complete: function(){ - $('#ow-notifications-loader').addClass('ow-hide'); - }, - success: function (res) { - nextPageUrl = res.next; - if ((res.count === 0) || ((res.results.length === 0) && (nextPageUrl === null) )) { - // If response does not have any notification, show no-notifications message. - $('.ow-no-notifications').removeClass('ow-hide'); - $('#ow-mark-all-read').addClass('disabled'); - if ($('#ow-show-unread').html() !== 'Show all') { - $('#ow-show-unread').addClass('disabled'); - } - busy = false; - } else { - if (res.results.length === 0 && nextPageUrl !== null){ - fetchNextPage(); - } - fetchedPages.push(res.results); - appendPage(); - // Enable filters - $('.toggle-btn').removeClass('disabled'); - } - }, - error: function (error) { - busy = false; - showNotificationDropdownError( - gettext('Failed to fetch notifications. Try again later.') - ); - throw error; - }, - }); + } + + function pageUp() { + busy = true; + if (lastRenderedPage > renderedPages) { + $(".ow-notification-wrapper div.page:last").remove(); + var addedDiv = pageContainer( + fetchedPages[lastRenderedPage - renderedPages - 1] + ); + $(".ow-notification-wrapper").prepend(addedDiv); + lastRenderedPage -= 1; } - - function pageDown() { - busy = true; - if (fetchedPages.length > lastRenderedPage) { - appendPage(); - } else if (nextPageUrl !== null) { - fetchNextPage(); - } else { - busy = false; - } + busy = false; + } + + function onUpdate() { + if (!busy) { + var scrollTop = $(".ow-notification-wrapper").scrollTop(), + scrollBottom = scrollTop + $(".ow-notification-wrapper").innerHeight(), + height = $(".ow-notification-wrapper")[0].scrollHeight; + if (height * 0.9 <= scrollBottom) { + pageDown(); + } else if (height * 0.1 >= scrollTop) { + pageUp(); + } } - - function pageUp() { - busy = true; - if (lastRenderedPage > renderedPages) { - $('.ow-notification-wrapper div.page:last').remove(); - var addedDiv = pageContainer(fetchedPages[lastRenderedPage - renderedPages - 1]); - $('.ow-notification-wrapper').prepend(addedDiv); - lastRenderedPage -= 1; - } - busy = false; + } + + function notificationListItem(elem) { + let klass; + const datetime = dateTimeStampToDateTimeLocaleString( + new Date(elem.timestamp) + ), + // target_url can be null or '#', so we need to handle it without any errors + target_url = new URL(elem.target_url, window.location.href); + + if (!notificationReadStatus.has(elem.id)) { + if (elem.unread) { + notificationReadStatus.set(elem.id, "unread"); + } else { + notificationReadStatus.set(elem.id, "read"); + } } + klass = notificationReadStatus.get(elem.id); - function onUpdate() { - if (!busy) { - var scrollTop = $('.ow-notification-wrapper').scrollTop(), - scrollBottom = scrollTop + $('.ow-notification-wrapper').innerHeight(), - height = $('.ow-notification-wrapper')[0].scrollHeight; - if (height * 0.90 <= scrollBottom) { - pageDown(); - } else if (height * 0.10 >= scrollTop) { - pageUp(); - } - } + let message; + if (elem.description) { + // Remove hyperlinks from generic notifications to enforce the opening of the message dialog + message = elem.message.replace(/]*>([^<]*)<\/a>/g, "$1"); + } else { + message = convertMessageWithRelativeURL(elem.message); } - function notificationListItem(elem) { - let klass; - const datetime = dateTimeStampToDateTimeLocaleString(new Date(elem.timestamp)), - // target_url can be null or '#', so we need to handle it without any errors - target_url = new URL(elem.target_url, window.location.href); - - if (!notificationReadStatus.has(elem.id)) { - if (elem.unread) { - notificationReadStatus.set(elem.id, 'unread'); - } else { - notificationReadStatus.set(elem.id, 'read'); - } - } - klass = notificationReadStatus.get(elem.id); - - let message; - if (elem.description) { - // Remove hyperlinks from generic notifications to enforce the opening of the message dialog - message = elem.message.replace(/]*>([^<]*)<\/a>/g, '$1'); - } else { - message = convertMessageWithRelativeURL(elem.message); - } - - return `
@@ -265,195 +276,207 @@ function notificationWidget($) { ${message}
`; + } + + function initNotificationWidget() { + $(".ow-notification-wrapper").on("scroll", onUpdate); + $(".ow-notification-wrapper").trigger("refreshNotificationWidget"); + $(".ow-notifications").off("click", initNotificationWidget); + } + + function refreshNotificationWidget( + e = null, + url = "/api/v1/notifications/notification/" + ) { + $(".ow-notification-wrapper > div").remove(".page"); + fetchedPages.length = 0; + lastRenderedPage = 0; + nextPageUrl = getAbsoluteUrl(url); + notificationReadStatus.clear(); + onUpdate(); + } + + function showNotificationDropdownError(message) { + $("#ow-notification-dropdown-error").html(message); + $("#ow-notification-dropdown-error-container").slideDown(1000); + setTimeout(closeNotificationDropdownError, 10000); + } + + function closeNotificationDropdownError() { + $("#ow-notification-dropdown-error-container").slideUp(1000, function () { + $("#ow-notification-dropdown-error").html(""); + }); + } + + $("#ow-notification-dropdown-error-container").on( + "click mouseleave focusout", + closeNotificationDropdownError + ); + + $(".ow-notifications").on("click", initNotificationWidget); + + // Handler for marking all notifications read + $("#ow-mark-all-read").click(function () { + var unreads = $(".ow-notification-elem.unread"); + unreads.removeClass("unread"); + $("#ow-notification-count").hide(); + $.ajax({ + type: "POST", + url: getAbsoluteUrl("/api/v1/notifications/notification/read/"), + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + xhrFields: { + withCredentials: true, + }, + crossDomain: true, + success: function () { + $("#ow-notification-count").remove(); + }, + error: function (error) { + unreads.addClass("unread"); + $("#ow-notification-count").show(); + showNotificationDropdownError( + gettext("Failed to mark notifications as unread. Try again later.") + ); + throw error; + }, + }); + }); + + // Handler for marking notification as read and opening target url + $(".ow-notification-wrapper").on( + "click keypress", + ".ow-notification-elem", + function (e) { + // Open target URL only when "Enter" key is pressed + if (e.type === "keypress" && e.which !== 13) { + return; + } + let elem = $(this); + notificationHandler($, elem); } + ); - function initNotificationWidget() { - $('.ow-notification-wrapper').on('scroll', onUpdate); - $('.ow-notification-wrapper').trigger('refreshNotificationWidget'); - $('.ow-notifications').off('click', initNotificationWidget); - } - - function refreshNotificationWidget(e = null, url = '/api/v1/notifications/notification/') { - $('.ow-notification-wrapper > div').remove('.page'); - fetchedPages.length = 0; - lastRenderedPage = 0; - nextPageUrl = getAbsoluteUrl(url); - notificationReadStatus.clear(); - onUpdate(); - } - - function showNotificationDropdownError(message) { - $('#ow-notification-dropdown-error').html(message); - $('#ow-notification-dropdown-error-container').slideDown(1000); - setTimeout(closeNotificationDropdownError, 10000); + // Close dialog on click, keypress or esc + $(".ow-dialog-close").on("click keypress", function (e) { + if (e.type === "keypress" && e.which !== 13 && e.which !== 27) { + return; } - - function closeNotificationDropdownError() { - $('#ow-notification-dropdown-error-container').slideUp(1000, function () { - $('#ow-notification-dropdown-error').html(''); - }); + $(".ow-overlay-notification").addClass("ow-hide"); + $(".ow-message-target-redirect").addClass("ow-hide"); + }); + + // Handler for marking notification as read on mouseout event + $(".ow-notification-wrapper").on( + "mouseleave focusout", + ".ow-notification-elem", + function () { + let elem = $(this); + if (elem.hasClass("unread")) { + markNotificationRead(elem.get(0)); + } } - - $('#ow-notification-dropdown-error-container').on( - 'click mouseleave focusout', - closeNotificationDropdownError - ); - - $('.ow-notifications').on('click', initNotificationWidget); - - // Handler for filtering unread notifications - $('#ow-show-unread').click(function () { - if ($(this).html().includes('Show unread only')) { - refreshNotificationWidget(null, '/api/v1/notifications/notification/?unread=true'); - $(this).html('Show all'); - } else { - refreshNotificationWidget(null, '/api/v1/notifications/notification/'); - $(this).html('Show unread only'); - } - }); - - // Handler for marking all notifications read - $('#ow-mark-all-read').click(function () { - var unreads = $('.ow-notification-elem.unread'); - unreads.removeClass('unread'); - $('#ow-notification-count').hide(); - $.ajax({ - type: 'POST', - url: getAbsoluteUrl('/api/v1/notifications/notification/read/'), - headers: { - 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() - }, - xhrFields: { - withCredentials: true - }, - crossDomain: true, - success: function () { - $('#ow-show-unread').html('Show unread only'); - $('#ow-notification-count').remove(); - }, - error: function (error) { - unreads.addClass('unread'); - $('#ow-notification-count').show(); - showNotificationDropdownError( - gettext('Failed to mark notifications as unread. Try again later.') - ); - throw error; - }, - }); - }); - - // Handler for marking notification as read and opening target url - $('.ow-notification-wrapper').on('click keypress', '.ow-notification-elem', function (e) { - // Open target URL only when "Enter" key is pressed - if ((e.type === 'keypress') && (e.which !== 13)){ - return; - } - let elem = $(this); - notificationHandler($, elem); - }); - - // Close dialog on click, keypress or esc - $('.ow-dialog-close').on('click keypress', function (e) { - if (e.type === 'keypress' && e.which !== 13 && e.which !== 27) { - return; - } - $('.ow-overlay-notification').addClass('ow-hide'); - $('.ow-message-target-redirect').addClass('ow-hide'); - }); - - // Handler for marking notification as read on mouseout event - $('.ow-notification-wrapper').on('mouseleave focusout', '.ow-notification-elem', function () { - let elem = $(this); - if (elem.hasClass('unread')) { - markNotificationRead(elem.get(0)); - } - }); - $('.ow-notification-wrapper').bind('refreshNotificationWidget', refreshNotificationWidget); + ); + $(".ow-notification-wrapper").bind( + "refreshNotificationWidget", + refreshNotificationWidget + ); } function markNotificationRead(elem) { - let elemId = elem.id.replace('ow-', ''); - try { - document.querySelector(`#${elem.id}.ow-notification-elem`).classList.remove('unread'); - } catch (error) { - // no op - } - notificationReadStatus.set(elemId, 'read'); - notificationSocket.send( - JSON.stringify({ - type: 'notification', - notification_id: elemId - }) - ); + let elemId = elem.id.replace("ow-", ""); + try { + document + .querySelector(`#${elem.id}.ow-notification-elem`) + .classList.remove("unread"); + } catch (error) { + // no op + } + notificationReadStatus.set(elemId, "read"); + notificationSocket.send( + JSON.stringify({ + type: "notification", + notification_id: elemId, + }) + ); } function notificationHandler($, elem) { - var notification = fetchedPages.flat().find((notification) => - notification.id == elem.get(0).id.replace('ow-', '')), - targetUrl = elem.data('location'); - - // If notification is unread then send read request - if (!notification.description && elem.hasClass('unread')) { - markNotificationRead(elem.get(0)); - } - - if (notification.target_url && notification.target_url !== '#') { - targetUrl = new URL(notification.target_url).pathname; - $('.ow-message-target-redirect').removeClass('ow-hide'); - } - - // Notification with overlay dialog - if (notification.description) { - var datetime = dateTimeStampToDateTimeLocaleString(new Date(notification.timestamp)); + var notification = fetchedPages + .flat() + .find( + (notification) => notification.id == elem.get(0).id.replace("ow-", "") + ), + targetUrl = elem.data("location"); + + // If notification is unread then send read request + if (!notification.description && elem.hasClass("unread")) { + markNotificationRead(elem.get(0)); + } + + if (notification.target_url && notification.target_url !== "#") { + targetUrl = new URL(notification.target_url).pathname; + $(".ow-message-target-redirect").removeClass("ow-hide"); + } + + // Notification with overlay dialog + if (notification.description) { + var datetime = dateTimeStampToDateTimeLocaleString( + new Date(notification.timestamp) + ); - $('.ow-dialog-notification-level-wrapper').html(` + $(".ow-dialog-notification-level-wrapper").html(`
${notification.level}
${datetime}
`); - $('.ow-message-title').html(convertMessageWithRelativeURL(notification.message)); - $('.ow-message-description').html(notification.description); - $('.ow-overlay-notification').removeClass('ow-hide'); + $(".ow-message-title").html( + convertMessageWithRelativeURL(notification.message) + ); + $(".ow-message-description").html(notification.description); + $(".ow-overlay-notification").removeClass("ow-hide"); - $(document).on('click', '.ow-message-target-redirect', function() { - window.location = targetUrl; - }); + $(document).on("click", ".ow-message-target-redirect", function () { + window.location = targetUrl; + }); // standard notification - } else { - window.location = targetUrl; - } + } else { + window.location = targetUrl; + } } function initWebSockets($) { - notificationSocket.addEventListener('message', function (e) { - let data = JSON.parse(e.data); - if (data.type !== 'notification') { - return; - } + notificationSocket.addEventListener("message", function (e) { + let data = JSON.parse(e.data); + if (data.type !== "notification") { + return; + } - // Update notification count - let countTag = $('#ow-notification-count'); - if (data.notification_count === 0) { - countTag.remove(); - } else { - // If unread tag is not present than insert it. - // Otherwise, update innerHTML. - if (countTag.length === 0) { - let html = `${data.notification_count}`; - $('.ow-notifications').append(html); - } else { - countTag.html(data.notification_count); - } - } - // Check whether to update notification widget - if (data.reload_widget) { - $('.ow-notification-wrapper').trigger('refreshNotificationWidget'); - } - // Check whether to display notification toast - if (data.notification) { - let toast = $(`
${data.notification_count}`; + $(".ow-notifications").append(html); + } else { + countTag.html(data.notification_count); + } + } + // Check whether to update notification widget + if (data.reload_widget) { + $(".ow-notification-wrapper").trigger("refreshNotificationWidget"); + } + // Check whether to display notification toast + if (data.notification) { + let toast = + $(`
@@ -462,53 +485,53 @@ function initWebSockets($) { ${data.notification.message}
`); - $('.ow-notification-toast-wrapper').prepend(toast); - if (owNotificationWindow.canPlaySound()){ - // Play notification sound only from authorized window - notificationSound.currentTime = 0; - notificationSound.play(); - } - toast.slideDown('slow', function () { - setTimeout(function () { - toast.slideUp('slow', function () { - toast.remove(); - }); - }, 30000); - }); - } - }); - // Make toast message clickable - $(document).on('click', '.ow-notification-toast', function () { - markNotificationRead($(this).get(0)); - notificationHandler($, $(this)); - }); - $(document).on('click', '.ow-notification-toast .ow-notify-close.btn', function (event) { - event.stopPropagation(); - let toast = $(this).parent(); - markNotificationRead(toast.get(0)); - toast.slideUp('slow'); - }); + $(".ow-notification-toast-wrapper").prepend(toast); + if (owNotificationWindow.canPlaySound()) { + // Play notification sound only from authorized window + notificationSound.currentTime = 0; + notificationSound.play(); + } + toast.slideDown("slow", function () { + setTimeout(function () { + toast.slideUp("slow", function () { + toast.remove(); + }); + }, 30000); + }); + } + }); + // Make toast message clickable + $(document).on("click", ".ow-notification-toast", function () { + markNotificationRead($(this).get(0)); + notificationHandler($, $(this)); + }); + $(document).on( + "click", + ".ow-notification-toast .ow-notify-close.btn", + function (event) { + event.stopPropagation(); + let toast = $(this).parent(); + markNotificationRead(toast.get(0)); + toast.slideUp("slow"); + } + ); } function getAbsoluteUrl(url) { - return notificationApiHost.origin + url; + return notificationApiHost.origin + url; } function dateTimeStampToDateTimeLocaleString(dateTimeStamp) { - let date = dateTimeStamp.toLocaleDateString( - userLanguage, { - day: 'numeric', - month: 'short', - year: 'numeric' - } - ), - time = dateTimeStamp.toLocaleTimeString( - userLanguage, { - hour: 'numeric', - minute: 'numeric' - } - ), - at = gettext('at'), - dateTimeString = `${date} ${at} ${time}`; - return dateTimeString; + let date = dateTimeStamp.toLocaleDateString(userLanguage, { + day: "numeric", + month: "short", + year: "numeric", + }), + time = dateTimeStamp.toLocaleTimeString(userLanguage, { + hour: "numeric", + minute: "numeric", + }), + at = gettext("at"), + dateTimeString = `${date} ${at} ${time}`; + return dateTimeString; } diff --git a/openwisp_notifications/static/openwisp-notifications/js/object-notifications.js b/openwisp_notifications/static/openwisp-notifications/js/object-notifications.js index 913759a0..89fd7af0 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/object-notifications.js +++ b/openwisp_notifications/static/openwisp-notifications/js/object-notifications.js @@ -1,20 +1,20 @@ -'use strict'; +"use strict"; (function ($) { - $(document).ready(function () { - if (typeof owIsChangeForm === "undefined") { - // Don't add object notification widget if - // it is not a change form. - return; - } - $('.object-tools').prepend(getObjectNotificationComponent()); - initObjectNotificationDropdown($); - addObjectNotificationHandlers($); - addObjectNotificationWSHandlers($); - }); + $(document).ready(function () { + if (typeof owIsChangeForm === "undefined") { + // Don't add object notification widget if + // it is not a change form. + return; + } + $(".object-tools").prepend(getObjectNotificationComponent()); + initObjectNotificationDropdown($); + addObjectNotificationHandlers($); + addObjectNotificationWSHandlers($); + }); })(django.jQuery); function getObjectNotificationComponent() { - return ` + return `
  • @@ -34,155 +34,187 @@ function getObjectNotificationComponent() { } function initObjectNotificationDropdown($) { - $(document).on('click', '.ow-object-notify', function (e) { - e.preventDefault(); - $('.ow-object-notification-option-container').toggleClass('ow-hide'); - }); - $(document).click(function (e) { - e.stopPropagation(); - // Check if the clicked area is dropDown / ow-notify-btn or not - if ( - $('.ow-object-notification-option-container').has(e.target).length === 0 && - !$(e.target).is($('.ow-object-notify')) - ) { - $('.ow-object-notification-option-container').addClass('ow-hide'); - } - }); - $(document).on('focusin', function (e) { - // Hide dropdown while accessing dropdown through keyboard - e.stopPropagation(); - if ($('.ow-object-notification-option-container').has(e.target).length === 0) { - $('.ow-object-notification-option-container').addClass('ow-hide'); - } - }); - $('.ow-object-notification-option-container').on('keyup', '*', function(e){ - e.stopPropagation(); - // Hide dropdown on "Escape" key - if (e.keyCode == 27){ - $('.ow-object-notification-option-container').addClass('ow-hide'); - $('#ow-object-notify').focus(); - } - }); + $(document).on("click", ".ow-object-notify", function (e) { + e.preventDefault(); + $(".ow-object-notification-option-container").toggleClass("ow-hide"); + }); + $(document).click(function (e) { + e.stopPropagation(); + // Check if the clicked area is dropDown / ow-notify-btn or not + if ( + $(".ow-object-notification-option-container").has(e.target).length === + 0 && + !$(e.target).is($(".ow-object-notify")) + ) { + $(".ow-object-notification-option-container").addClass("ow-hide"); + } + }); + $(document).on("focusin", function (e) { + // Hide dropdown while accessing dropdown through keyboard + e.stopPropagation(); + if ( + $(".ow-object-notification-option-container").has(e.target).length === 0 + ) { + $(".ow-object-notification-option-container").addClass("ow-hide"); + } + }); + $(".ow-object-notification-option-container").on("keyup", "*", function (e) { + e.stopPropagation(); + // Hide dropdown on "Escape" key + if (e.keyCode == 27) { + $(".ow-object-notification-option-container").addClass("ow-hide"); + $("#ow-object-notify").focus(); + } + }); } function addObjectNotificationHandlers($) { - // Click handler for disabling notifications - $(document).on('click', 'button.ow-notification-option.disable-notification', function (e) { - e.stopPropagation(); - let validTill, daysOffset = $(this).data('days'); - if (daysOffset === -1) { - validTill = undefined; - } else { - validTill = new Date(); - validTill.setDate(validTill.getDate() + daysOffset); - validTill = validTill.toISOString(); - } + // Click handler for disabling notifications + $(document).on( + "click", + "button.ow-notification-option.disable-notification", + function (e) { + e.stopPropagation(); + let validTill, + daysOffset = $(this).data("days"); + if (daysOffset === -1) { + validTill = undefined; + } else { + validTill = new Date(); + validTill.setDate(validTill.getDate() + daysOffset); + validTill = validTill.toISOString(); + } - $.ajax({ - type: 'PUT', - url: getAbsoluteUrl(`/api/v1/notifications/notification/ignore/${owNotifyAppLabel}/${owNotifyModelName}/${owNotifyObjectId}/`), - headers: { - 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() - }, - xhrFields: { - withCredentials: true - }, - beforeSend: function () { - $('.ow-object-notification-option-container > button').addClass('ow-hide'); - $('#ow-object-notification-loader').removeClass('ow-hide'); - }, - data: { - valid_till: validTill, - }, - crossDomain: true, - success: function () { - updateObjectNotificationHelpText($, validTill); - $('#ow-object-notification-loader').addClass('ow-hide'); - }, - error: function (error) { - throw error; - }, - }); - }); + $.ajax({ + type: "PUT", + url: getAbsoluteUrl( + `/api/v1/notifications/notification/ignore/${owNotifyAppLabel}/${owNotifyModelName}/${owNotifyObjectId}/` + ), + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + xhrFields: { + withCredentials: true, + }, + beforeSend: function () { + $(".ow-object-notification-option-container > button").addClass( + "ow-hide" + ); + $("#ow-object-notification-loader").removeClass("ow-hide"); + }, + data: { + valid_till: validTill, + }, + crossDomain: true, + success: function () { + updateObjectNotificationHelpText($, validTill); + $("#ow-object-notification-loader").addClass("ow-hide"); + }, + error: function (error) { + throw error; + }, + }); + } + ); - // Click handler for enabling notifications - $(document).on('click', '#ow-enable-notification', function (e) { - e.stopPropagation(); - $.ajax({ - type: 'DELETE', - url: getAbsoluteUrl(`/api/v1/notifications/notification/ignore/${owNotifyAppLabel}/${owNotifyModelName}/${owNotifyObjectId}/`), - headers: { - 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() - }, - xhrFields: { - withCredentials: true - }, - beforeSend: function () { - $('.ow-object-notification-option-container > button').addClass('ow-hide'); - $('#ow-object-notification-loader').removeClass('ow-hide'); - }, - crossDomain: true, - success: function () { - $('#ow-object-notify > span.ow-icon').removeClass('ow-object-notify-slash-bell'); - $('#ow-object-notify > span.ow-icon').addClass('ow-object-notify-bell'); - $('#ow-silence-label').html('Silence notifications'); - $('#ow-object-notify').prop('title', 'You are receiving notifications for this object.'); + // Click handler for enabling notifications + $(document).on("click", "#ow-enable-notification", function (e) { + e.stopPropagation(); + $.ajax({ + type: "DELETE", + url: getAbsoluteUrl( + `/api/v1/notifications/notification/ignore/${owNotifyAppLabel}/${owNotifyModelName}/${owNotifyObjectId}/` + ), + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + xhrFields: { + withCredentials: true, + }, + beforeSend: function () { + $(".ow-object-notification-option-container > button").addClass( + "ow-hide" + ); + $("#ow-object-notification-loader").removeClass("ow-hide"); + }, + crossDomain: true, + success: function () { + $("#ow-object-notify > span.ow-icon").removeClass( + "ow-object-notify-slash-bell" + ); + $("#ow-object-notify > span.ow-icon").addClass("ow-object-notify-bell"); + $("#ow-silence-label").html("Silence notifications"); + $("#ow-object-notify").prop( + "title", + "You are receiving notifications for this object." + ); - $('#ow-notification-help-text').html(`Disable notifications for this object`); - $('#ow-object-notification-loader').addClass('ow-hide'); - $('.ow-notification-option.disable-notification').removeClass('ow-hide'); - $('.ow-object-notification-option-container > button:visible:first').focus(); - }, - error: function (error) { - throw error; - }, - }); + $("#ow-notification-help-text").html( + `Disable notifications for this object` + ); + $("#ow-object-notification-loader").addClass("ow-hide"); + $(".ow-notification-option.disable-notification").removeClass( + "ow-hide" + ); + $( + ".ow-object-notification-option-container > button:visible:first" + ).focus(); + }, + error: function (error) { + throw error; + }, }); + }); } function addObjectNotificationWSHandlers($) { - if (notificationSocket.readyState === 1) { - openHandler(); - } - notificationSocket.addEventListener('open', openHandler); - - notificationSocket.addEventListener('message', function (e) { - let data = JSON.parse(e.data); - if (data.type !== 'object_notification') { - return; - } - if (data.hasOwnProperty('valid_till')) { - updateObjectNotificationHelpText($, data.valid_till); - } - }); + if (notificationSocket.readyState === 1) { + openHandler(); + } + notificationSocket.addEventListener("open", openHandler); - function openHandler() { - let data = { - type: 'object_notification', - object_id: owNotifyObjectId, - app_label: owNotifyAppLabel, - model_name: owNotifyModelName - }; - notificationSocket.send(JSON.stringify(data)); + notificationSocket.addEventListener("message", function (e) { + let data = JSON.parse(e.data); + if (data.type !== "object_notification") { + return; + } + if (data.hasOwnProperty("valid_till")) { + updateObjectNotificationHelpText($, data.valid_till); } + }); + + function openHandler() { + let data = { + type: "object_notification", + object_id: owNotifyObjectId, + app_label: owNotifyAppLabel, + model_name: owNotifyModelName, + }; + notificationSocket.send(JSON.stringify(data)); + } } function updateObjectNotificationHelpText($, validTill) { - let disabledText; - if ((validTill === null) || (validTill === undefined)) { - disabledText = `Disabled permanently`; - } else { - let dateTimeString = dateTimeStampToDateTimeLocaleString(new Date(validTill)); - disabledText = `Disabled till ${dateTimeString}`; - } + let disabledText; + if (validTill === null || validTill === undefined) { + disabledText = `Disabled permanently`; + } else { + let dateTimeString = dateTimeStampToDateTimeLocaleString( + new Date(validTill) + ); + disabledText = `Disabled till ${dateTimeString}`; + } - $('#ow-notification-help-text').html(disabledText); - $('#ow-enable-notification').removeClass('ow-hide'); - $('.ow-notification-option.disable-notification').addClass('ow-hide'); - $('.ow-object-notification-option-container > button:visible:first').focus(); + $("#ow-notification-help-text").html(disabledText); + $("#ow-enable-notification").removeClass("ow-hide"); + $(".ow-notification-option.disable-notification").addClass("ow-hide"); + $(".ow-object-notification-option-container > button:visible:first").focus(); - $('#ow-object-notify > span.ow-icon').removeClass('ow-object-notify-bell'); - $('#ow-object-notify > span.ow-icon').addClass('ow-object-notify-slash-bell'); - $('#ow-silence-label').html('Unsilence notifications'); - $('#ow-object-notify').prop('title', 'You have disabled notifications for this object.'); + $("#ow-object-notify > span.ow-icon").removeClass("ow-object-notify-bell"); + $("#ow-object-notify > span.ow-icon").addClass("ow-object-notify-slash-bell"); + $("#ow-silence-label").html("Unsilence notifications"); + $("#ow-object-notify").prop( + "title", + "You have disabled notifications for this object." + ); } diff --git a/openwisp_notifications/static/openwisp-notifications/js/preferences.js b/openwisp_notifications/static/openwisp-notifications/js/preferences.js new file mode 100644 index 00000000..89ab42b9 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/js/preferences.js @@ -0,0 +1,984 @@ +"use strict"; + +gettext = gettext || ((word) => word); + +function getAbsoluteUrl(url) { + return notificationApiHost.origin + url; +} + +(function ($) { + let isUpdateInProgress = false; + let globalSettingId = null; + + $(document).ready(function () { + const userId = $(".settings-container").data("user-id"); + fetchNotificationSettings(userId); + initializeGlobalSettings(userId); + }); + + function fetchNotificationSettings(userId) { + let allResults = []; + + function fetchPage(url) { + $.ajax({ + url: url, + dataType: "json", + beforeSend: function () { + $(".loader").show(); + $(".global-settings").hide(); + }, + complete: function () { + $(".loader").hide(); + }, + success: function (data) { + allResults = allResults.concat(data.results); + + if (data.next) { + // Continue fetching next page + 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) + ); + + if (globalSetting) { + const isGlobalWebChecked = globalSetting.web; + const isGlobalEmailChecked = globalSetting.email; + globalSettingId = globalSetting.id; + + initializeGlobalDropdowns(isGlobalWebChecked, isGlobalEmailChecked); + } else { + showToast("error", gettext("Global settings not found.")); + } + + // Group and render settings by organization_id + const groupedData = groupBy(filteredResults, "organization"); + renderNotificationSettings(groupedData); + + initializeEventListeners(userId); + $(".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 + ); + return result; + }, {}); + } + + function renderNotificationSettings(data) { + const orgPanelsContainer = $("#org-panels").empty(); + + if (Object.keys(data).length === 0) { + orgPanelsContainer.append(` +
    + ${gettext("No organizations available.")} +
    + `); + return; + } + + // Render settings for each organization + Object.keys(data).forEach(function (orgId, orgIndex) { + const orgSettings = data[orgId]; + 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("Web")} ${enabledWebNotifications}/${totalNotifications} +

    +
    +
    + `); + + if (orgSettings.length > 0) { + const tableBody = $(` + + + ${gettext("Notification Type")} + +
    + ${gettext("Web")} + ? + +
    + + +
    + ${gettext("Email")} + ? + +
    + + + + `); + + // Populate table rows with individual settings + orgSettings.forEach((setting, settingIndex) => { + const row = $(` + + ${setting.type_label} + + + + + + + + `); + tableBody.append(row); + }); + + updateMainCheckboxes(tableBody); + orgPanel.find("table").append(tableBody); + } else { + orgPanel.append(` +
    + ${gettext("No settings available for this organization")} +
    + `); + } + orgPanelsContainer.append(orgPanel); + }); + + // Expand the first organization if there is only one organization + if (Object.keys(data).length === 1) { + $("#org-panels .toggle-icon").click(); + } + } + + // Update the org level checkboxes + function updateMainCheckboxes(table) { + table.find(".org-toggle").each(function () { + const column = $(this).data("column"); + 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 + ); + }); + } + + 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"); + + if (orgContent.hasClass("active")) { + orgContent.slideUp("fast", function () { + orgContent.removeClass("active"); + toggleIcon.removeClass("expanded").addClass("collapsed"); + }); + } else { + orgContent.addClass("active").slideDown(); + toggleIcon.removeClass("collapsed").addClass("expanded"); + } + }); + + // Event listener for Individual notification setting + $(document).on("change", ".email-checkbox, .web-checkbox", function () { + // Prevent multiple simultaneous updates + if (isUpdateInProgress) { + return; + } + + 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"); + + // Store previous states for potential rollback + let previousWebChecked, previousEmailChecked; + if (triggeredBy === "email") { + previousEmailChecked = !isEmailChecked; + previousWebChecked = isWebChecked; + } else { + previousWebChecked = !isWebChecked; + 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; + + // 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); + + $.ajax({ + type: "PATCH", + url: `/api/v1/notifications/user/${userId}/user-setting/${settingId}/`, + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + contentType: "application/json", + 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); + updateOrgLevelCheckboxes(organizationId); + }, + complete: function () { + isUpdateInProgress = false; + }, + }); + }); + + // Event listener for organization level checkbox changes + $(document).on("change", ".org-toggle", function () { + // Prevent multiple simultaneous updates + if (isUpdateInProgress) { + return; + } + + const table = $(this).closest("table"); + const orgId = $(this).data("organization-id"); + const triggeredBy = $(this).data("column"); + + let isOrgWebChecked = $( + `.org-toggle[data-organization-id="${orgId}"][data-column="web"]` + ).is(":checked"); + let isOrgEmailChecked = $( + `.org-toggle[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(); + + 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; + } else { + previousOrgWebChecked = !isOrgWebChecked; + 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; + } + + isUpdateInProgress = true; + + const data = { + web: isOrgWebChecked, + }; + + if (triggeredBy === "email") { + data.email = isOrgEmailChecked; + } + + // Update the UI + $(`.org-toggle[data-organization-id="${orgId}"][data-column="web"]`).prop( + "checked", + isOrgWebChecked + ); + $( + `.org-toggle[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(); + } + + updateMainCheckboxes(table); + updateOrgLevelCheckboxes(orgId); + + $.ajax({ + type: "POST", + url: getAbsoluteUrl( + `/api/v1/notifications/user/${userId}/organization/${orgId}/setting/` + ), + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + contentType: "application/json", + data: JSON.stringify(data), + success: function () { + showToast( + "success", + gettext("Organization settings updated successfully.") + ); + }, + error: function () { + showToast( + "error", + gettext("Something went wrong. Please try again.") + ); + $( + `.org-toggle[data-organization-id="${orgId}"][data-column="web"]` + ).prop("checked", previousOrgWebChecked); + $( + `.org-toggle[data-organization-id="${orgId}"][data-column="email"]` + ).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 () { + isUpdateInProgress = false; + }, + }); + }); + } + + // Update individual setting checkboxes and counts at the organization level + function updateOrgLevelCheckboxes(organizationId) { + const table = $( + `.org-toggle[data-organization-id="${organizationId}"]` + ).closest("table"); + const webCheckboxes = table.find(".web-checkbox"); + const emailCheckboxes = table.find(".email-checkbox"); + const webMainCheckbox = table.find('.org-toggle[data-column="web"]'); + const emailMainCheckbox = table.find('.org-toggle[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 + ); + } + + 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(); + let openClass = "global-setting-dropdown-menu-open"; + let isMenuOpen = $menu.hasClass(openClass); + closeAllDropdowns(); + if (!isMenuOpen) { + $menu.addClass(openClass); + } + adjustDropdownWidth($menu); + }); + + $menu.find("button").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("button") + .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; + } + + 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: $('.org-toggle[data-column="web"]') + .map(function () { + return { + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + mainEmailChecked: $('.org-toggle[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"; + } + + // Email notifications require web notifications to be enabled + if (triggeredBy === "email" && isGlobalEmailChecked) { + isGlobalWebChecked = true; + } + + // 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 + $('.org-toggle[data-column="web"]') + .prop("checked", isGlobalWebChecked) + .change(); + $(".web-checkbox").prop("checked", isGlobalWebChecked); + if ( + (dropdownType === "web" && !isGlobalWebChecked) || + dropdownType === "email" + ) { + $(".email-checkbox").prop("checked", isGlobalEmailChecked); + $('.org-toggle[data-column="email"]') + .prop("checked", isGlobalEmailChecked) + .change(); + } + + var data = JSON.stringify({ + web: isGlobalWebChecked, + email: isGlobalEmailChecked, + }); + + $(".module").each(function () { + const organizationId = $(this) + .find(".org-toggle") + .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) { + $( + `.org-toggle[data-organization-id="${item.orgId}"][data-column="web"]` + ).prop("checked", item.checked); + }); + previousCheckboxStates.mainEmailChecked.forEach(function (item) { + $( + `.org-toggle[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(".org-toggle") + .data("organization-id"); + updateOrgLevelCheckboxes(organizationId); + }); + }, + complete: function () { + isUpdateInProgress = false; + }, + }); + } + $modal.hide(); + }); + + // 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"); + + // 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 (!newGlobalWebChecked) { + newGlobalEmailChecked = false; + } + + // 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") + + "." + ); + } + + // Set the modal icon + if (dropdownType === "web") { + $modalIcon.html('
    '); + } else if (dropdownType === "email") { + $modalIcon.html('
    '); + } + + // 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"); + } + + // Update the modal message + var changesList = getChangeList(changes); + var message = + "The following settings will be applied:
    " + + changesList + + "Do you want to continue?"; + $modalMessage.html(message); + } + + function getChangeList(changes) { + var changesList = "
      "; + changes.forEach(function (change) { + changesList += "
    • " + change + "
    • "; + }); + changesList += "
    "; + return changesList; + } + } + + function showToast(level, message) { + const existingToast = document.querySelector(".toast"); + if (existingToast) { + document.body.removeChild(existingToast); + } + + const toast = document.createElement("div"); + toast.className = `toast ${level}`; + toast.innerHTML = ` +
    +
    + ${message} +
    +
    + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = "1"; + }, 10); + + const progressBar = toast.querySelector(".progress-bar"); + progressBar.style.transition = "width 3000ms linear"; + setTimeout(() => { + progressBar.style.width = "0%"; + }, 10); + + setTimeout(() => { + toast.style.opacity = "0"; + setTimeout(() => { + if (document.body.contains(toast)) { + document.body.removeChild(toast); + } + }, 500); + }, 3000); + + toast.addEventListener("click", () => { + if (document.body.contains(toast)) { + document.body.removeChild(toast); + } + }); + } + + function createArrowSpanHtml() { + return ''; + } +})(django.jQuery); diff --git a/openwisp_notifications/tasks.py b/openwisp_notifications/tasks.py index f1f6cdc4..05de2bdb 100644 --- a/openwisp_notifications/tasks.py +++ b/openwisp_notifications/tasks.py @@ -82,10 +82,24 @@ 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: + notification_config = types.get_notification_configuration(type) 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 + and notification_config.get('email_notification'), + 'web': global_setting.web + and notification_config.get('web_notification'), + }, + user=user, + type=type, + organization=org, ) diff --git a/openwisp_notifications/templates/admin/base_site.html b/openwisp_notifications/templates/admin/base_site.html index 4861d9f1..bc8a8f33 100644 --- a/openwisp_notifications/templates/admin/base_site.html +++ b/openwisp_notifications/templates/admin/base_site.html @@ -26,7 +26,7 @@
    diff --git a/openwisp_notifications/templates/admin/openwisp_users/user/change_form_object_tools.html b/openwisp_notifications/templates/admin/openwisp_users/user/change_form_object_tools.html new file mode 100644 index 00000000..ce26feea --- /dev/null +++ b/openwisp_notifications/templates/admin/openwisp_users/user/change_form_object_tools.html @@ -0,0 +1,12 @@ +{% extends "admin/change_form_object_tools.html" %} + +{% load i18n admin_urls %} + +{% block object-tools-items %} + {% if request.user.is_staff and original.is_staff %} +
  • + Notification Preferences +
  • + {% endif %} + {{ block.super }} +{% endblock %} diff --git a/openwisp_notifications/templates/openwisp_notifications/preferences.html b/openwisp_notifications/templates/openwisp_notifications/preferences.html new file mode 100644 index 00000000..07a7b76c --- /dev/null +++ b/openwisp_notifications/templates/openwisp_notifications/preferences.html @@ -0,0 +1,99 @@ +{% extends "admin/base_site.html" %} + +{% load i18n %} +{% load static %} + +{% block title %} + {% trans "Notification Preferences" %} +{% endblock title %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock extrastyle %} + +{% block breadcrumbs %} + +{% endblock breadcrumbs %} + +{% block content %} +
    +
    +
    +

    {% trans 'Global Settings' %}

    +
    + +
    +
    +
    +
    +

    {% trans 'Web' %}

    +

    + {% trans 'Enable or Disable all web notifications globally' %} +

    +
    + +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    {% trans 'Email' %}

    +

    {% trans 'Enable or Disable all email notifications globally' %}

    +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +{% endblock content %} + +{% block footer %} + {{ block.super }} + {% if request.user.is_authenticated %} + + {% endif %} +{% endblock footer %} diff --git a/openwisp_notifications/tests/test_admin.py b/openwisp_notifications/tests/test_admin.py index 7dcd6843..35e48ef4 100644 --- a/openwisp_notifications/tests/test_admin.py +++ b/openwisp_notifications/tests/test_admin.py @@ -3,14 +3,12 @@ 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 from django.urls import reverse from openwisp_notifications import settings as app_settings -from openwisp_notifications.admin import NotificationSettingInline from openwisp_notifications.signals import notify from openwisp_notifications.swapper import load_model, swapper_load_model from openwisp_notifications.widgets import _add_object_notification_widget @@ -63,7 +61,6 @@ def setUp(self): url='localhost:8000/admin', ) self.site = AdminSite() - self.ns_inline = NotificationSettingInline(NotificationSetting, self.site) @property def _url(self): @@ -159,86 +156,34 @@ def test_websocket_protocol(self): response = self.client.get(self._url) self.assertContains(response, 'wss') - def test_notification_setting_inline_read_only_fields(self): - with self.subTest('Test for superuser'): - self.assertListEqual(self.ns_inline.get_readonly_fields(su_request), []) - - with self.subTest('Test for non-superuser'): - self.assertListEqual( - self.ns_inline.get_readonly_fields(op_request), - ['type', 'organization'], - ) - - def test_notification_setting_inline_add_permission(self): - with self.subTest('Test for superuser'): - self.assertTrue(self.ns_inline.has_add_permission(su_request)) - - with self.subTest('Test for non-superuser'): - self.assertFalse( - self.ns_inline.has_add_permission(op_request), - ) - - def test_notification_setting_inline_delete_permission(self): - with self.subTest('Test for superuser'): - self.assertTrue(self.ns_inline.has_delete_permission(su_request)) - - with self.subTest('Test for non-superuser'): - self.assertFalse(self.ns_inline.has_delete_permission(op_request)) - - def test_notification_setting_inline_organization_formfield(self): - response = self.client.get( - reverse('admin:openwisp_users_user_change', args=(self.admin.pk,)) - ) - organization = self._get_org(org_name='default') - self.assertContains( - response, - f'', - ) - - def test_notification_setting_inline_admin_has_change_permission(self): - with self.subTest('Test for superuser'): - self.assertTrue( - self.ns_inline.has_change_permission(su_request), - ) - - with self.subTest('Test for non-superuser'): - self.assertFalse( - self.ns_inline.has_change_permission(op_request), - ) - self.assertTrue( - self.ns_inline.has_change_permission(op_request, obj=op_request.user), - ) - - 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.assertContains(response, 'User notification settings') - 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.full_clean() + 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 56f53abc..09fdfa5a 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -259,7 +259,9 @@ 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 = NotificationSetting.objects.exclude( + organization=None + ).first() notification_setting_count = NotificationSetting.objects.count() token = self._obtain_auth_token(username='admin', password='tester') @@ -544,29 +546,34 @@ 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) 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') + 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) @@ -576,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) @@ -592,8 +601,40 @@ def test_list_notification_setting_filtering(self): ns = response.data['results'].pop() self.assertEqual(ns['type'], 'default') + with self.subTest('Test without authenticated'): + self.client.logout() + user_url = self._get_path('user_notification_setting_list', tester.pk) + response = self.client.get(user_url) + self.assertEqual(response.status_code, 401) + + with self.subTest('Test filtering by user_id as admin'): + self.client.force_login(self.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.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()) @@ -613,8 +654,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.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'): @@ -622,7 +704,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, @@ -638,6 +720,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 @@ -671,6 +804,94 @@ def _unread_notification(notification): '{view}?next={url}'.format(view=reverse('admin:login'), url=url), ) + def test_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 + ) + NotificationSetting.objects.filter( + user=self.admin, organization_id=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) + 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) + 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) + + with self.subTest('Test with invalid data'): + self.client.force_login(self.admin) + url = self._get_path( + 'organization_notification_setting', + self.admin.pk, + org.pk, + ) + response = self.client.post(url, data={'web': 'invalid'}) + self.assertEqual(response.status_code, 400) + + with self.subTest( + 'Test email to False while keeping one of email notification setting to true' + ): + url = self._get_path( + 'organization_notification_setting', + self.admin.pk, + 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' + ).update(email=True) + + response = self.client.post(url, data={'web': True, 'email': False}) + + 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, 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 254ddb9f..16a2712b 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,6 +133,20 @@ def test_post_migration_handler(self): queryset.filter(user=org_user.user).count(), 1 * notification_types_count ) + # Check Global Notification Setting is created + 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() admin.is_superuser = False @@ -251,3 +276,85 @@ def test_deleted_notificationsetting_autocreated(self): self.assertEqual(ns_queryset.count(), 1) 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.full_clean() + global_setting.save() + + with self.subTest( + '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( + user=admin, organization=org, type='default' + ).update(email=True) + + global_setting.web = True + global_setting.full_clean() + global_setting.save() + + self.assertTrue( + NotificationSetting.objects.filter( + user=admin, organization=org, email=True, type='default' + ).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( + user=admin, type=None, organization=None + ) + self.assertEqual(str(global_setting), 'Global Setting') + global_setting.delete() + self.assertEqual( + NotificationSetting.objects.filter( + user=admin, type=None, organization=None + ).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 555eb060..2f7c3430 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta from unittest.mock import patch +from uuid import uuid4 from allauth.account.models import EmailAddress from celery.exceptions import OperationalError @@ -132,7 +133,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() @@ -800,13 +801,18 @@ def test_notification_type_web_notification_setting_false(self): self.assertEqual(notification_queryset.count(), 0) with self.subTest('Test user email preference is "True"'): + unregister_notification_type('test_type') + test_type.update({'web_notification': True}) + register_notification_type('test_type', test_type) + self.notification_options.update({'type': 'test_type'}) + notification_setting = NotificationSetting.objects.get( user=self.admin, type='test_type', organization=target_obj.organization ) 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( @@ -1047,6 +1053,38 @@ def test_that_the_notification_is_only_sent_once_to_the_user(self): self._create_notification() self.assertEqual(notification_queryset.count(), 1) + def test_notification_preference_page(self): + preference_page = 'notifications:user_notification_preference' + tester = self._create_user(username='tester') + + with self.subTest('Test user is not authenticated'): + response = self.client.get(reverse(preference_page, args=(self.admin.pk,))) + self.assertEqual(response.status_code, 302) + + with self.subTest('Test with same user'): + self.client.force_login(self.admin) + response = self.client.get(reverse('notifications:notification_preference')) + self.assertEqual(response.status_code, 200) + + with self.subTest('Test user is authenticated'): + self.client.force_login(self.admin) + response = self.client.get(reverse(preference_page, args=(self.admin.pk,))) + self.assertEqual(response.status_code, 200) + + with self.subTest('Test user is authenticated but not superuser'): + self.client.force_login(tester) + response = self.client.get(reverse(preference_page, args=(self.admin.pk,))) + self.assertEqual(response.status_code, 403) + + with self.subTest('Test user is authenticated and superuser'): + self.client.force_login(self.admin) + response = self.client.get(reverse(preference_page, args=(tester.pk,))) + self.assertEqual(response.status_code, 200) + + with self.subTest('Test invalid user ID'): + response = self.client.get(reverse(preference_page, args=(uuid4(),))) + self.assertEqual(response.status_code, 404) + class TestTransactionNotifications(TestOrganizationMixin, TransactionTestCase): def setUp(self): diff --git a/openwisp_notifications/tests/test_widget.py b/openwisp_notifications/tests/test_selenium.py similarity index 54% rename from openwisp_notifications/tests/test_widget.py rename to openwisp_notifications/tests/test_selenium.py index 48bebeca..75a9368b 100644 --- a/openwisp_notifications/tests/test_widget.py +++ b/openwisp_notifications/tests/test_selenium.py @@ -4,25 +4,27 @@ from selenium.webdriver.support.ui import WebDriverWait from openwisp_notifications.signals import notify -from openwisp_notifications.swapper import load_model +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') -class TestWidget( +class TestSelenium( SeleniumTestMixin, TestOrganizationMixin, StaticLiveServerTestCase, ): - serve_static = True - def setUp(self): self.admin = self._create_admin( username=self.admin_username, password=self.admin_password ) + org = self._create_org() + OrganizationUser.objects.create(user=self.admin, organization=org) self.operator = super()._get_operator() self.notification_options = dict( sender=self.admin, @@ -93,3 +95,80 @@ def test_notification_dialog_open_button_visibility(self): 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/') + # Uncheck the global web checkbox + WebDriverWait(self.web_driver, 10).until( + EC.element_to_be_clickable( + ( + By.CSS_SELECTOR, + '.global-setting-dropdown[data-web-state] .global-setting-dropdown-toggle', + ) + ) + ).click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located( + ( + By.CSS_SELECTOR, + '.global-setting-dropdown[data-web-state]' + ' .global-setting-dropdown-menu button:last-child', + ) + ) + ).click() + + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, '#confirmation-modal #confirm') + ) + ).click() + all_checkboxes = self.web_driver.find_elements( + By.CSS_SELECTOR, 'input[type="checkbox"]' + ) + 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.element_to_be_clickable((By.CSS_SELECTOR, '#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-"]' + ) + for checkbox in web_checkboxes: + self.assertTrue(checkbox.is_displayed()) + self.assertTrue(checkbox.is_selected()) + + # Check a single email checkbox + 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 29156165..7bd17d22 100644 --- a/openwisp_notifications/tests/test_utils.py +++ b/openwisp_notifications/tests/test_utils.py @@ -107,7 +107,7 @@ def run_check(): self.assertIn(error_message, error.hint) with self.subTest('Test setting dotted path is not subclass of ModelAdmin'): - path = 'openwisp_notifications.admin.NotificationSettingInline' + 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"' diff --git a/openwisp_notifications/urls.py b/openwisp_notifications/urls.py index efc6d2ba..8e83ff4d 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 .views import notification_preference_page def get_urls(api_views=None, social_views=None): @@ -10,7 +11,17 @@ def get_urls(api_views=None, social_views=None): api_views(optional): views for Notifications API """ urls = [ - path('api/v1/notifications/notification/', include(get_api_urls(api_views))) + path('api/v1/notifications/', include(get_api_urls(api_views))), + path( + 'notifications/preferences/', + notification_preference_page, + name='notification_preference', + ), + path( + 'notifications/user//preferences/', + notification_preference_page, + name='user_notification_preference', + ), ] return urls diff --git a/openwisp_notifications/views.py b/openwisp_notifications/views.py new file mode 100644 index 00000000..fdc38546 --- /dev/null +++ b/openwisp_notifications/views.py @@ -0,0 +1,46 @@ +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 + +User = get_user_model() + + +class NotificationPreferencePage(LoginRequiredMixin, UserPassesTestMixin, TemplateView): + template_name = 'openwisp_notifications/preferences.html' + login_url = reverse_lazy('admin:login') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user_id = self.kwargs.get('pk') + context['title'] = _('Notification Preferences') + + if user_id: + try: + user = User.objects.only('id', 'username').get(pk=user_id) + # Only admin should access other users preferences + context['username'] = user.username + context['title'] += f' ({user.username})' + except User.DoesNotExist: + raise Http404('User does not exist') + else: + user = self.request.user + + context['user_id'] = user.id + 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_superuser + or self.request.user.id == self.kwargs.get('pk') + ) + return True + + +notification_preference_page = NotificationPreferencePage.as_view() diff --git a/run-qa-checks b/run-qa-checks index b0dff247..08241934 100755 --- a/run-qa-checks +++ b/run-qa-checks @@ -1,9 +1,6 @@ #!/bin/bash set -e -jshint openwisp_notifications/static/openwisp-notifications/js/*.js -stylelint "openwisp_notifications/static/openwisp-notifications/css/*.css" - openwisp-qa-check \ --csslinter \ --jslinter \ diff --git a/tests/openwisp2/sample_notifications/admin.py b/tests/openwisp2/sample_notifications/admin.py index 375655d1..97d5e91b 100644 --- a/tests/openwisp2/sample_notifications/admin.py +++ b/tests/openwisp2/sample_notifications/admin.py @@ -1,7 +1,3 @@ -# isort:skip_file -from openwisp_notifications.admin import NotificationSettingInline # noqa - - # Used for testing of openwisp-notifications from django.contrib import admin diff --git a/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization_and_more.py b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization_and_more.py new file mode 100644 index 00000000..e8c94403 --- /dev/null +++ b/tests/openwisp2/sample_notifications/migrations/0003_alter_notificationsetting_organization_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.16 on 2024-09-17 13:27 + +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", + ), + ), + 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", + ), + ), + ]