diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07404263..9dff2b0f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,10 +5,13 @@ on: branches: - master - dev + - gsoc24-rebased pull_request: branches: - master - dev + - gsoc24-rebased + - notification-preferences jobs: diff --git a/docs/user/intro.rst b/docs/user/intro.rst index 2ff8ab49..e6aca6b0 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -7,7 +7,8 @@ include: - :doc:`sending-notifications` - :ref:`notifications_web_notifications` -- :ref:`notifications_email_notifications` +- :ref:`notifications_email_notifications` and + :ref:`notifications_batches` - :doc:`notification-types` - :doc:`User notification preferences ` - :ref:`Silencing notifications for specific objects temporarily or 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/docs/user/settings.rst b/docs/user/settings.rst index 1ad84bb1..2dde9377 100644 --- a/docs/user/settings.rst +++ b/docs/user/settings.rst @@ -156,3 +156,34 @@ The default configuration is as follows: # Maximum interval after which the notification widget should get updated (in seconds) "max_allowed_backoff": 15, } + +.. _openwisp_notifications_email_batch_interval: + +``OPENWISP_NOTIFICATIONS_EMAIL_BATCH_INTERVAL`` +----------------------------------------------- + +======= ================================= +Type ``int`` +Default ``1800`` (30 minutes, in seconds) +======= ================================= + +This setting determines the :ref:`interval of the email batching feature +`. + +The interval is specified in seconds. + +To send email notifications immediately without batching, set this value +to ``0``. + +.. _openwisp_notifications_email_batch_display_limit: + +``OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT`` +---------------------------------------------------- + +======= ======= +Type ``int`` +Default ``15`` +======= ======= + +This setting specifies the maximum number of email notifications that can +be included in a single :ref:`email batch `. diff --git a/docs/user/web-email-notifications.rst b/docs/user/web-email-notifications.rst index dd57a332..150c99fe 100644 --- a/docs/user/web-email-notifications.rst +++ b/docs/user/web-email-notifications.rst @@ -54,3 +54,38 @@ Email Notifications Along with web notifications OpenWISP Notifications also sends email notifications leveraging the :ref:`send_email feature of OpenWISP Utils `. + +.. _notifications_batches: + +Email Batches +~~~~~~~~~~~~~ + +.. figure:: https://i.imgur.com/W5P009W.png + :target: https://i.imgur.com/W5P009W.png + :align: center + +Batching email notifications helps manage the flow of emails sent to +users, especially during periods of increased alert activity. By grouping +emails into batches, the system minimizes the risk of emails being marked +as spam and prevents inboxes from rejecting alerts due to high volumes. + +Key aspects of the batch email notification feature include: + +- When multiple emails are triggered for the same user within a short time + frame, subsequent emails are grouped into a summary. +- The sending of individual emails is paused for a specified batch + interval when batching is enabled. + +.. note:: + + If new alerts are received while a batch is pending, they will be + added to the current summary without resetting the timer. The batched + email will be sent when the initial batch interval expires. + +You can customize the behavior of batch email notifications using the +following settings: + +- :ref:`OPENWISP_NOTIFICATIONS_EMAIL_BATCH_INTERVAL + `. +- :ref:`OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT + `. 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..b50d2160 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=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 5925920d..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 @@ -139,7 +140,7 @@ def message(self): @cached_property def rendered_description(self): if not self.description: - return + return '' with notification_render_attributes(self): data = self.data or {} desc = self.description.format(notification=self, **data) @@ -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 6949ab28..f247e67a 100644 --- a/openwisp_notifications/handlers.py +++ b/openwisp_notifications/handlers.py @@ -10,7 +10,6 @@ from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone -from django.utils.translation import gettext as _ from openwisp_notifications import settings as app_settings from openwisp_notifications import tasks @@ -20,8 +19,8 @@ NOTIFICATION_ASSOCIATED_MODELS, get_notification_configuration, ) +from openwisp_notifications.utils import send_notification_email from openwisp_notifications.websockets import handlers as ws_handlers -from openwisp_utils.admin_theme.email import send_email logger = logging.getLogger(__name__) @@ -171,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( @@ -192,34 +192,46 @@ def send_email_notification(sender, instance, created, **kwargs): if not (email_preference and instance.recipient.email and email_verified): return - try: - subject = instance.email_subject - except NotificationRenderException: - # Do not send email if notification is malformed. - return - url = instance.data.get('url', '') if instance.data else None - body_text = instance.email_message - if url: - target_url = url - elif instance.target: - target_url = instance.redirect_view_url - else: - target_url = None - if target_url: - body_text += _('\n\nFor more information see %(target_url)s.') % { - 'target_url': target_url - } - - send_email( - subject=subject, - body_text=body_text, - body_html=instance.email_message, - recipients=[instance.recipient.email], - extra_context={ - 'call_to_action_url': target_url, - 'call_to_action_text': _('Find out more'), + recipient_id = instance.recipient.id + cache_key = f'email_batch_{recipient_id}' + + cache_data = cache.get( + cache_key, + { + 'last_email_sent_time': None, + 'batch_scheduled': False, + 'pks': [], + 'start_time': None, + 'email_id': instance.recipient.email, }, ) + EMAIL_BATCH_INTERVAL = app_settings.EMAIL_BATCH_INTERVAL + + if cache_data['last_email_sent_time'] and EMAIL_BATCH_INTERVAL > 0: + # Case 1: Batch email sending logic + if not cache_data['batch_scheduled']: + # Schedule batch email notification task if not already scheduled + tasks.send_batched_email_notifications.apply_async( + (instance.recipient.id,), countdown=EMAIL_BATCH_INTERVAL + ) + # Mark batch as scheduled to prevent duplicate scheduling + cache_data['batch_scheduled'] = True + cache_data['pks'] = [instance.id] + cache_data['start_time'] = timezone.now() + cache.set(cache_key, cache_data) + else: + # Add current instance ID to the list of IDs for batch + cache_data['pks'].append(instance.id) + cache.set(cache_key, cache_data) + return + + # Case 2: Single email sending logic + # Update the last email sent time and cache the data + if EMAIL_BATCH_INTERVAL > 0: + cache_data['last_email_sent_time'] = timezone.now() + cache.set(cache_key, cache_data, timeout=EMAIL_BATCH_INTERVAL) + + send_notification_email(instance) # flag as emailed instance.emailed = True 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/settings.py b/openwisp_notifications/settings.py index 448cf4b9..46eacb06 100644 --- a/openwisp_notifications/settings.py +++ b/openwisp_notifications/settings.py @@ -36,6 +36,15 @@ 'OPENWISP_NOTIFICATIONS_SOUND', 'openwisp-notifications/audio/notification_bell.mp3', ) + +EMAIL_BATCH_INTERVAL = getattr( + settings, 'OPENWISP_NOTIFICATIONS_EMAIL_BATCH_INTERVAL', 30 * 60 # 30 minutes +) + +EMAIL_BATCH_DISPLAY_LIMIT = getattr( + settings, 'OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT', 15 +) + # Remove the leading "/static/" here as it will # conflict with the "static()" call in context_processors.py. # This is done for backward compatibility. diff --git a/openwisp_notifications/static/openwisp-notifications/css/notifications.css b/openwisp_notifications/static/openwisp-notifications/css/notifications.css index a8b27932..14a65197 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/notifications.css +++ b/openwisp_notifications/static/openwisp-notifications/css/notifications.css @@ -113,7 +113,7 @@ float: right; position: relative; right: -2px; - bottom: -8px; + bottom: -3px; background-size: 9px; } .ow-notification-toast.info .icon { @@ -144,7 +144,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; 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..ce5f990a --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/css/preferences.css @@ -0,0 +1,292 @@ +.global-settings { + margin: 10px 0 20px; + display: none; +} +.global-settings-container { + display: flex; + border: 1px solid #e0e0e0; + border-radius: 8px; +} +.icon { + min-width: 24px; + min-height: 24px; + padding-right: 6px; +} +.icon-web { + background: url("../../openwisp-notifications/images/icons/icon-web.svg") + 0 0 no-repeat; +} +.icon-email { + background: url("../../openwisp-notifications/images/icons/icon-email.svg") + 0 0 no-repeat; +} +.global-setting-text h2 { + margin: 0 0 5px 0; +} +.global-setting-text p { + color: #666; +} +.global-setting-divider { + width: 1px; + background-color: #e0e0e0; +} +.global-setting-dropdown { + position: relative; +} +.global-setting-dropdown-toggle .mg-arrow { + display: block; +} +.global-setting-dropdown-toggle { + display: flex; + padding: 10px 15px; + background-color: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; +} +.global-setting-dropdown-menu { + display: none; + position: absolute; + background-color: #fff; + border: 1px solid #e0e0e0; + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 0; + margin: 0; +} +.global-setting-dropdown-menu li { + padding: 10px 15px; + cursor: pointer; +} +.global-setting-dropdown-menu-open { + display: block; +} +.global-setting { + flex: 1; + padding: 20px; +} +.global-settings-container { + width: 840px; +} +.global-setting-content { + display: flex; + margin-bottom: 10px; +} +.global-setting-content h2 { + color: #555; +} +.modal { + display: none; + position: fixed; + z-index: 9999; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.4); +} +.modal-content { + background-color: #fff; + margin: 15% auto; + padding: 20px; + border: 1px solid #888; + width: 400px; + border-radius: 5px; +} +.modal-header { + margin-bottom: 20px; +} +.modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} +#go-back, +#confirm { + width: 100%; +} +.module h2 { + padding: 15px 10px; + cursor: pointer; + display: flex; + justify-content: space-around; + align-items: center; + font-weight: bold; + font-size: 14px; + text-transform: uppercase; + padding: 6px; +} +.toggle-header { + border: none !important; +} +.org-name { + width: 40%; +} +.email-row { + position: relative; +} +.org-content { + margin-top: 0; + padding-top: 0; + display: none; +} +.org-content.active { + display: block; +} +table { + width: 100%; +} +table:not(.toggle-header) th:not(:last-child), +table:not(.toggle-header) td:not(:last-child) { + border-right: 1px solid #ddd; +} +th:not(:first-child), +td:not(:first-child) { + text-align: center; +} +.no-settings, +.no-organizations { + padding: 10px; + text-align: center; + color: #666; +} +.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; +} +.toast .progress-bar { + position: absolute; + bottom: 0; + left: 0; + height: 4px; + background-color: #007bff; + width: 100%; + transition: width 3s linear; +} +span.toggle-icon { + position: absolute; + right: 15px; + top: 15px; + width: 16px; + height: 16px; + margin-right: 5px; + background: url(/static/admin/img/sorting-icons.svg) 0 0 no-repeat; + background-size: 20px auto; +} +span.toggle-icon.collapsed { + background-position: 0px -84px; +} +span.toggle-icon.expanded { + background-position: 0px -44px; +} +.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: 3px 6px; + border-radius: 2px; + font-size: 10px; + white-space: nowrap; + visibility: hidden; +} +.tooltip-icon:hover::after { + visibility: visible; +} +.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); +} +.notification-web-header, +.notification-email-header { + text-align: center; +} +.notification-header-container { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.slider.round { + border-radius: 20px; +} +.slider.round:before { + border-radius: 50%; +} +.module { + border: 1px solid rgba(0, 0, 0, 0.1); +} +ul > li { + list-style-type: none !important; +} diff --git a/openwisp_notifications/static/openwisp-notifications/css/unsubscribe.css b/openwisp_notifications/static/openwisp-notifications/css/unsubscribe.css new file mode 100644 index 00000000..d81b769d --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/css/unsubscribe.css @@ -0,0 +1,40 @@ +#content, .content { + padding: 0 !important; +} +.unsubscribe-container { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + height: 95vh; +} +.unsubscribe-content { + padding: 40px; + border-radius: 12px; + text-align: center; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1); + max-width: 500px; + width: 100%; +} +.unsubscribe-content h1 { + padding-top: 10px; +} +.logo { + width: 200px; + margin-bottom: 80px; +} +.email-icon { + background-image: url('../../openwisp-notifications/images/icons/icon-email.svg'); + width: 50px; + height: 50px; + margin: 0 auto; + transform: scale(2) translate(25%, 25%); +} +.footer { + margin-top: 20px; +} +#confirmation-msg { + color: green; + margin-top: 20px; + font-weight: bold; +} 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..2c56e8c8 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/images/icons/icon-email.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwisp_notifications/static/openwisp-notifications/images/icons/icon-web.svg b/openwisp_notifications/static/openwisp-notifications/images/icons/icon-web.svg new file mode 100644 index 00000000..212c6ae1 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/images/icons/icon-web.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwisp_notifications/static/openwisp-notifications/js/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 811ed155..c2a91f10 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/notifications.js +++ b/openwisp_notifications/static/openwisp-notifications/js/notifications.js @@ -100,6 +100,12 @@ function initNotificationDropDown($) { $('#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 @@ -164,9 +170,6 @@ function notificationWidget($) { // 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){ @@ -295,17 +298,6 @@ function notificationWidget($) { $('.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'); @@ -322,7 +314,6 @@ function notificationWidget($) { }, crossDomain: true, success: function () { - $('#ow-show-unread').html('Show unread only'); $('#ow-notification-count').remove(); }, error: function (error) { 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..c1a9cc89 --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/js/preferences.js @@ -0,0 +1,803 @@ +'use strict'; + +if (typeof gettext === 'undefined') { + var gettext = function(word){ return 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).sort().forEach(function(orgId, orgIndex) { + const orgSettings = data[orgId].sort(function(a, b) { + return a.type_label.localeCompare(b.type_label); + }); + + const orgName = orgSettings[0].organization_name; + + // 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 + '

' + + '
' + + '
' + ); + + const orgContent = orgPanel.find('.org-content'); + if (orgSettings.length > 0) { + const table = $( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + gettext('Notification Type') + '' + + '
' + + '' + gettext('Web') + '' + + '?' + + '' + + '
' + + '
' + + '
' + + '' + gettext('Email') + '' + + '?' + + '' + + '
' + + '
' + ); + + // Populate table rows with individual settings + orgSettings.forEach(function(setting, settingIndex) { + const row = $( + '' + + '' + setting.type_label + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ); + table.find('tbody').append(row); + }); + + orgContent.append(table); + updateMainCheckboxes(table); + } else { + orgContent.append('
' + gettext('No settings available for this organization') + '
'); + } + + orgPanelsContainer.append(orgPanel); + + // Expand the first organization if there is only one organization + if (orgIndex === 0 && orgSettings.length === 1) { + orgContent.addClass('active'); + orgPanel.find('.toggle-icon').removeClass('collapsed').addClass('expanded'); + } else { + orgContent.hide(); + } + }); + } + + // Update the org level checkboxes + function updateMainCheckboxes(table) { + table.find('.main-checkbox').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.removeClass('active').slideUp(); + 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', '.main-checkbox', function () { + // Prevent multiple simultaneous updates + if (isUpdateInProgress) { + return; + } + + const table = $(this).closest('table'); + const orgId = $(this).data('organization-id'); + const triggeredBy = $(this).data('column'); + + let isOrgWebChecked = $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).is(':checked'); + let isOrgEmailChecked = $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).is(':checked'); + + // Store previous states for potential rollback + let previousOrgWebChecked, previousOrgEmailChecked; + const previousWebState = table.find('.web-checkbox').map(function() { + return { id: $(this).data('pk'), checked: $(this).is(':checked') }; + }).get(); + + 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 + $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).prop('checked', isOrgWebChecked); + $(`.main-checkbox[data-organization-id="${orgId}"][data-column="email"]`).prop('checked', isOrgEmailChecked); + table.find('.web-checkbox').prop('checked', isOrgWebChecked).change(); + if ((triggeredBy === 'web' && !isOrgWebChecked) || triggeredBy === 'email') { + table.find('.email-checkbox').prop('checked', isOrgEmailChecked).change(); + } + + 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.')); + $(`.main-checkbox[data-organization-id="${orgId}"][data-column="web"]`).prop('checked', previousOrgWebChecked); + $(`.main-checkbox[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 = $(`.main-checkbox[data-organization-id="${organizationId}"]`).closest('table'); + const webCheckboxes = table.find('.web-checkbox'); + const emailCheckboxes = table.find('.email-checkbox'); + const webMainCheckbox = table.find('.main-checkbox[data-column="web"]'); + const emailMainCheckbox = table.find('.main-checkbox[data-column="email"]'); + const totalWebCheckboxes = webCheckboxes.length; + const totalEmailCheckboxes = emailCheckboxes.length; + const checkedWebCheckboxes = webCheckboxes.filter(':checked').length; + const checkedEmailCheckboxes = emailCheckboxes.filter(':checked').length; + + webMainCheckbox.prop('checked', totalWebCheckboxes === checkedWebCheckboxes); + emailMainCheckbox.prop('checked', totalEmailCheckboxes === checkedEmailCheckboxes); + + // Update counts in the header + const orgModule = table.closest('.module'); + const webCountSpan = orgModule.find('.web-count'); + const emailCountSpan = orgModule.find('.email-count'); + webCountSpan.text(gettext('Web') + ' ' + checkedWebCheckboxes + '/' + totalWebCheckboxes); + emailCountSpan.text(gettext('Email') + ' ' + checkedEmailCheckboxes + '/' + totalEmailCheckboxes); + } + + function initializeGlobalSettings(userId) { + var $dropdowns = $(".global-setting-dropdown"); + var $modal = $("#confirmation-modal"); + var $goBackBtn = $("#go-back"); + var $confirmBtn = $("#confirm"); + var activeDropdown = null; + var selectedOptionText = ""; + var selectedOptionElement = null; + var previousCheckboxStates = null; + + $dropdowns.each(function () { + var $dropdown = $(this); + var $toggle = $dropdown.find(".global-setting-dropdown-toggle"); + var $menu = $dropdown.find(".global-setting-dropdown-menu"); + + $toggle.on("click", function (e) { + e.stopPropagation(); + closeAllDropdowns(); + $menu.toggleClass("global-setting-dropdown-menu-open"); + adjustDropdownWidth($menu); + }); + + $menu.find("li").on("click", function () { + activeDropdown = $dropdown; + selectedOptionText = $(this).text().trim(); + selectedOptionElement = $(this); + updateModalContent(); // Update modal content before showing + $modal.show(); + }); + }); + + // Close all dropdowns when clicking outside + $(document).on("click", closeAllDropdowns); + + function closeAllDropdowns() { + $dropdowns.each(function () { + $(this) + .find(".global-setting-dropdown-menu") + .removeClass("global-setting-dropdown-menu-open"); + }); + } + + function adjustDropdownWidth($menu) { + var $toggle = $menu.prev(".global-setting-dropdown-toggle"); + var maxWidth = Math.max.apply( + null, + $menu + .find("li") + .map(function () { + return $(this).outerWidth(); + }) + .get() + ); + $menu.css( + "width", + Math.max($toggle.outerWidth(), maxWidth) + "px" + ); + } + + $goBackBtn.on("click", function () { + $modal.hide(); + }); + + $confirmBtn.on("click", function () { + if (isUpdateInProgress) { + return; + } + + if (activeDropdown) { + var dropdownType = activeDropdown.is("[data-web-state]") ? "web" : "email"; + var triggeredBy = dropdownType; + + var $webDropdown = $('.global-setting-dropdown[data-web-state]'); + var $emailDropdown = $('.global-setting-dropdown[data-email-state]'); + var $webToggle = $webDropdown.find('.global-setting-dropdown-toggle'); + var $emailToggle = $emailDropdown.find('.global-setting-dropdown-toggle'); + + // Determine the current states + var isGlobalWebChecked = $webToggle.attr('data-state') === 'on'; + var isGlobalEmailChecked = $emailToggle.attr('data-state') === 'on'; + + // Store previous states for potential rollback + var previousGlobalWebChecked = isGlobalWebChecked; + var previousGlobalEmailChecked = isGlobalEmailChecked; + + previousCheckboxStates = { + mainWebChecked: $('.main-checkbox[data-column="web"]') + .map(function () { + return { + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + mainEmailChecked: $('.main-checkbox[data-column="email"]') + .map(function () { + return { + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + webChecked: $(".web-checkbox") + .map(function () { + return { + id: $(this).data("pk"), + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + emailChecked: $(".email-checkbox") + .map(function () { + return { + id: $(this).data("pk"), + orgId: $(this).data("organization-id"), + checked: $(this).is(":checked"), + }; + }) + .get(), + }; + + // Update the state based on the selected option + if (dropdownType === "web") { + isGlobalWebChecked = selectedOptionText === "Notify on Web"; + } else if (dropdownType === "email") { + isGlobalEmailChecked = selectedOptionText === "Notify by Email"; + } + + // 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 + $('.main-checkbox[data-column="web"]') + .prop("checked", isGlobalWebChecked) + .change(); + $(".web-checkbox").prop("checked", isGlobalWebChecked); + if ( + (dropdownType === "web" && !isGlobalWebChecked) || + dropdownType === "email" + ) { + $(".email-checkbox").prop("checked", isGlobalEmailChecked); + $('.main-checkbox[data-column="email"]') + .prop("checked", isGlobalEmailChecked) + .change(); + } + + var data = JSON.stringify({ + web: isGlobalWebChecked, + email: isGlobalEmailChecked, + }); + + $('.module').each(function () { + const organizationId = $(this).find('.main-checkbox').data('organization-id'); + updateOrgLevelCheckboxes(organizationId); + }); + + $.ajax({ + type: "PATCH", + url: getAbsoluteUrl( + `/api/v1/notifications/user/${userId}/user-setting/${globalSettingId}/` + ), + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + contentType: "application/json", + data: data, + success: function () { + showToast( + "success", + gettext("Global settings updated successfully.") + ); + }, + error: function () { + showToast( + "error", + gettext("Something went wrong. Please try again.") + ); + + // Rollback the UI changes + isGlobalWebChecked = previousGlobalWebChecked; + isGlobalEmailChecked = previousGlobalEmailChecked; + + // Update the dropdown toggles and data-state attributes + $webToggle + .html( + (isGlobalWebChecked ? "Notify on Web" : "Don't Notify on Web") + + " " + + createArrowSpanHtml() + ) + .attr("data-state", isGlobalWebChecked ? "on" : "off"); + $webDropdown.attr("data-web-state", isGlobalWebChecked ? "Yes" : "No"); + + $emailToggle + .html( + (isGlobalEmailChecked ? "Notify by Email" : "Don't Notify by Email") + + " " + + createArrowSpanHtml() + ) + .attr("data-state", isGlobalEmailChecked ? "on" : "off"); + $emailDropdown.attr("data-email-state", isGlobalEmailChecked ? "Yes" : "No"); + + // Restore the checkboxes + previousCheckboxStates.mainWebChecked.forEach(function (item) { + $( + `.main-checkbox[data-organization-id="${item.orgId}"][data-column="web"]` + ).prop("checked", item.checked); + }); + previousCheckboxStates.mainEmailChecked.forEach(function (item) { + $( + `.main-checkbox[data-organization-id="${item.orgId}"][data-column="email"]` + ).prop("checked", item.checked); + }); + previousCheckboxStates.webChecked.forEach(function (item) { + $( + `.web-checkbox[data-organization-id="${item.orgId}"][data-pk="${item.id}"]` + ).prop("checked", item.checked); + }); + previousCheckboxStates.emailChecked.forEach(function (item) { + $( + `.email-checkbox[data-organization-id="${item.orgId}"][data-pk="${item.id}"]` + ).prop("checked", item.checked); + }); + + $('.module').each(function () { + const organizationId = $(this).find('.main-checkbox').data('organization-id'); + updateOrgLevelCheckboxes(organizationId); + }); + }, + complete: function () { + isUpdateInProgress = false; + }, + }); + } + $modal.hide(); + }); + + // 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 message = 'The following settings will be applied:
' + changes.join('
') + '
Do you want to continue?'; + $modalMessage.html(message); + } + } + + 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/static/openwisp-notifications/js/unsubscribe.js b/openwisp_notifications/static/openwisp-notifications/js/unsubscribe.js new file mode 100644 index 00000000..71954d4a --- /dev/null +++ b/openwisp_notifications/static/openwisp-notifications/js/unsubscribe.js @@ -0,0 +1,49 @@ +'use strict'; +if (typeof gettext === 'undefined') { + var gettext = function(word) { return word; }; +} + +function updateSubscription(subscribe) { + fetch(window.location.href, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ subscribe: subscribe }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + const toggleBtn = document.getElementById('toggle-btn'); + const statusMessage = document.getElementById('status-message'); + const confirmationMsg = document.getElementById('confirmation-msg'); + + if (subscribe) { + statusMessage.textContent = gettext('You are currently subscribed to notifications.'); + toggleBtn.textContent = gettext('Unsubscribe'); + toggleBtn.setAttribute('data-hasSubscribe', 'true'); + } else { + statusMessage.textContent = gettext('You are currently unsubscribed from notifications.'); + toggleBtn.textContent = gettext('Subscribe'); + toggleBtn.setAttribute('data-hasSubscribe', 'false'); + } + + confirmationMsg.textContent = data.message; + confirmationMsg.style.display = 'block'; + } else { + window.alert(data.message); + } + }) + .catch(error => { + window.console.error('Error:', error); + }); +} + +document.addEventListener('DOMContentLoaded', function() { + const toggleBtn = document.getElementById('toggle-btn'); + toggleBtn.addEventListener('click', function() { + const isSubscribed = toggleBtn.getAttribute('data-hasSubscribe') === 'true'; + const subscribe = !isSubscribed; + updateSubscription(subscribe); + }); +}); diff --git a/openwisp_notifications/tasks.py b/openwisp_notifications/tasks.py index 9869d46a..e6a5e8bf 100644 --- a/openwisp_notifications/tasks.py +++ b/openwisp_notifications/tasks.py @@ -3,12 +3,23 @@ from celery import shared_task from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.core.cache import cache from django.db.models import Q from django.db.utils import OperationalError +from django.template.loader import render_to_string from django.utils import timezone +from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ +from openwisp_notifications import settings as app_settings from openwisp_notifications import types from openwisp_notifications.swapper import load_model, swapper_load_model +from openwisp_notifications.utils import ( + generate_unsubscribe_link, + send_notification_email, +) +from openwisp_utils.admin_theme.email import send_email from openwisp_utils.tasks import OpenwispCeleryTask User = get_user_model() @@ -75,10 +86,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, ) @@ -202,3 +227,96 @@ def delete_ignore_object_notification(instance_id): Deletes IgnoreObjectNotification object post it's expiration. """ IgnoreObjectNotification.objects.filter(id=instance_id).delete() + + +@shared_task(base=OpenwispCeleryTask) +def send_batched_email_notifications(instance_id): + """ + Sends a summary of notifications to the specified email address. + """ + if not instance_id: + return + + cache_key = f'email_batch_{instance_id}' + cache_data = cache.get(cache_key, {'pks': []}) + + if not cache_data['pks']: + return + + display_limit = app_settings.EMAIL_BATCH_DISPLAY_LIMIT + unsent_notifications_query = Notification.objects.filter( + id__in=cache_data['pks'] + ).order_by('-timestamp') + notifications_count = unsent_notifications_query.count() + current_site = Site.objects.get_current() + email_id = cache_data.get('email_id') + unsent_notifications = [] + + # Send individual email if there is only one notification + if notifications_count == 1: + notification = unsent_notifications.first() + send_notification_email(notification) + else: + # Show the amount of notifications according to configured display limit + for notification in unsent_notifications_query[:display_limit]: + url = notification.data.get('url', '') if notification.data else None + if url: + notification.url = url + elif notification.target: + notification.url = notification.redirect_view_url + else: + notification.url = None + + unsent_notifications.append(notification) + + starting_time = ( + cache_data.get('start_time') + .strftime('%B %-d, %Y, %-I:%M %p') + .lower() + .replace('am', 'a.m.') + .replace('pm', 'p.m.') + ) + ' UTC' + + user = User.objects.get(id=instance_id) + + context = { + 'notifications': unsent_notifications[:display_limit], + 'notifications_count': notifications_count, + 'site_name': current_site.name, + 'start_time': starting_time, + } + + unsubscribe_link = generate_unsubscribe_link(user) + + extra_context = { + 'footer': mark_safe( + 'To unsubscribe from these notifications, ' + f'click here.' + ), + } + if notifications_count > display_limit: + extra_context = { + 'call_to_action_url': f'https://{current_site.domain}/admin/#notifications', + 'call_to_action_text': _('View all Notifications'), + } + context.update(extra_context) + + html_content = render_to_string('emails/batch_email.html', context) + plain_text_content = render_to_string('emails/batch_email.txt', context) + notifications_count = min(notifications_count, display_limit) + + send_email( + subject=f'[{current_site.name}] {notifications_count} new notifications since {starting_time}', + body_text=plain_text_content, + body_html=html_content, + recipients=[email_id], + extra_context=extra_context, + headers={ + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': f'<{unsubscribe_link}>', + }, + ) + + unsent_notifications_query.update(emailed=True) + Notification.objects.bulk_update(unsent_notifications_query, ['emailed']) + cache.delete(cache_key) 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 @@
{% trans 'Mark all as read' %} - {% trans 'Show unread only' %} + {% trans 'Notification Preferences' %}
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/emails/batch_email.html b/openwisp_notifications/templates/emails/batch_email.html new file mode 100644 index 00000000..cbeff54f --- /dev/null +++ b/openwisp_notifications/templates/emails/batch_email.html @@ -0,0 +1,107 @@ +{% block styles %} + +{% endblock styles %} + +{% block mail_body %} +
    + {% for notification in notifications %} +
    +

    + {{ notification.level|upper }} + + {% if notification.url %} + {{ notification.email_message }} + {% else %} + {{ notification.email_message }} + {% endif %} + +

    +

    {{ notification.timestamp|date:"F j, Y, g:i a" }}

    + {% if notification.rendered_description %} +

    {{ notification.rendered_description|safe }}

    + {% endif %} +
    + {% endfor %} +
    +{% endblock mail_body %} diff --git a/openwisp_notifications/templates/emails/batch_email.txt b/openwisp_notifications/templates/emails/batch_email.txt new file mode 100644 index 00000000..7e2d5ef0 --- /dev/null +++ b/openwisp_notifications/templates/emails/batch_email.txt @@ -0,0 +1,14 @@ +{% load i18n %} + +[{{ site_name }}] {{ notifications_count }} {% translate "new notifications since" %} {{ start_time }} + +{% for notification in notifications %} +- {{ notification.email_message }}{% if notification.rendered_description %} + {% translate "Description" %}: {{ notification.rendered_description }}{% endif %} + {% translate "Date & Time" %}: {{ notification.timestamp|date:"F j, Y, g:i a" }}{% if notification.url %} + {% translate "URL" %}: {{ notification.url }}{% endif %} +{% endfor %} + +{% if call_to_action_url %} +{{ call_to_action_text }}: {{ call_to_action_url }} +{% endif %} diff --git a/openwisp_notifications/templates/openwisp_notifications/preferences.html b/openwisp_notifications/templates/openwisp_notifications/preferences.html new file mode 100644 index 00000000..4170f4eb --- /dev/null +++ b/openwisp_notifications/templates/openwisp_notifications/preferences.html @@ -0,0 +1,94 @@ +{% 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 %} +
    +
    +
    +

    Global Settings

    +
    + +
    +
    +
    +
    +

    Web

    +

    Enable or Disable all web notifications globally

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

    Email

    +

    Enable or Disable all email notifications globally

    +
    + +
      +
    • Notify by Email
    • +
    • Don't Notify by Email
    • +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +{% endblock content %} + +{% block footer %} + {{ block.super }} + {% if request.user.is_authenticated %} + + {% endif %} +{% endblock footer %} diff --git a/openwisp_notifications/templates/openwisp_notifications/unsubscribe.html b/openwisp_notifications/templates/openwisp_notifications/unsubscribe.html new file mode 100644 index 00000000..dbb54c7b --- /dev/null +++ b/openwisp_notifications/templates/openwisp_notifications/unsubscribe.html @@ -0,0 +1,55 @@ +{% extends 'account/base_entrance.html' %} +{% load i18n %} +{% load static %} + +{% block head_title %} + {% trans 'Manage Subscription Preferences' %} +{% endblock %} + +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} + +{% block menu-bar %} +{% endblock %} + +{% block content %} +
    + +
    + +

    {% trans 'Manage Notification Preferences' %}

    + {% if valid %} +

    + {% if is_subscribed %} + {% trans 'You are currently subscribed to notifications.' %} + {% else %} + {% trans 'You are currently unsubscribed from notifications.' %} + {% endif %} +

    + + + {% else %} +

    {% trans 'Invalid or Expired Link' %}

    +

    {% trans 'The link you used is invalid or expired.' %}

    + {% endif %} + +
    +
    +{% endblock %} 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 0b510d7d..d000c57e 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -1,5 +1,7 @@ -from datetime import timedelta +import json +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 @@ -30,11 +32,12 @@ register_notification_type, unregister_notification_type, ) +from openwisp_notifications.tokens import email_token_generator from openwisp_notifications.types import ( _unregister_notification_choice, get_notification_configuration, ) -from openwisp_notifications.utils import _get_absolute_url +from openwisp_notifications.utils import _get_absolute_url, generate_unsubscribe_link from openwisp_users.tests.utils import TestOrganizationMixin from openwisp_utils.tests import capture_any_output @@ -132,7 +135,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 +803,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( @@ -944,6 +952,92 @@ def test_notification_for_unverified_email(self): # we don't send emails to unverified email addresses self.assertEqual(len(mail.outbox), 0) + @patch('openwisp_notifications.tasks.send_batched_email_notifications.apply_async') + def test_batch_email_notification(self, mock_send_email): + fixed_datetime = datetime(2024, 7, 26, 11, 40) + + with patch.object(timezone, 'now', return_value=fixed_datetime): + for _ in range(5): + notify.send(recipient=self.admin, **self.notification_options) + + # Check if only one mail is sent initially + self.assertEqual(len(mail.outbox), 1) + + # Call the task + tasks.send_batched_email_notifications(self.admin.id) + + # Check if the rest of the notifications are sent in a batch + self.assertEqual(len(mail.outbox), 2) + + expected_subject = ( + '[example.com] 4 new notifications since july 26, 2024, 11:40 a.m. UTC' + ) + expected_body = """ +[example.com] 4 new notifications since july 26, 2024, 11:40 a.m. UTC + + +- Test Notification + Description: Test Notification + Date & Time: July 26, 2024, 11:40 a.m. + URL: https://localhost:8000/admin + +- Test Notification + Description: Test Notification + Date & Time: July 26, 2024, 11:40 a.m. + URL: https://localhost:8000/admin + +- Test Notification + Description: Test Notification + Date & Time: July 26, 2024, 11:40 a.m. + URL: https://localhost:8000/admin + +- Test Notification + Description: Test Notification + Date & Time: July 26, 2024, 11:40 a.m. + URL: https://localhost:8000/admin + """ + + self.assertEqual(mail.outbox[1].subject, expected_subject) + self.assertEqual(mail.outbox[1].body.strip(), expected_body.strip()) + + @patch('openwisp_notifications.tasks.send_batched_email_notifications.apply_async') + def test_batch_email_notification_with_call_to_action(self, mock_send_email): + self.notification_options.update( + { + 'message': 'Notification title', + 'type': 'default', + } + ) + display_limit = app_settings.EMAIL_BATCH_DISPLAY_LIMIT + for _ in range(display_limit + 2): + notify.send(recipient=self.admin, **self.notification_options) + + # Check if only one mail is sent initially + self.assertEqual(len(mail.outbox), 1) + + # Call the task + tasks.send_batched_email_notifications(self.admin.id) + + # Check if the rest of the notifications are sent in a batch + self.assertEqual(len(mail.outbox), 2) + self.assertIn( + f'{display_limit} new notifications since', mail.outbox[1].subject + ) + self.assertIn('View all Notifications', mail.outbox[1].body) + + @patch.object(app_settings, 'EMAIL_BATCH_INTERVAL', 0) + def test_without_batch_email_notification(self): + self.notification_options.update( + { + 'message': 'Notification title', + 'type': 'default', + } + ) + for _ in range(3): + notify.send(recipient=self.admin, **self.notification_options) + + self.assertEqual(len(mail.outbox), 3) + def test_that_the_notification_is_only_sent_once_to_the_user(self): first_org = self._create_org() first_org.organization_id = first_org.id @@ -961,6 +1055,145 @@ def test_that_the_notification_is_only_sent_once_to_the_user(self): self._create_notification() self.assertEqual(notification_queryset.count(), 1) + def test_email_unsubscribe_token(self): + token = email_token_generator.make_token(self.admin) + + with self.subTest('Valid token for the user'): + is_valid = email_token_generator.check_token(self.admin, token) + self.assertTrue(is_valid) + + with self.subTest('Token used with a different user'): + test_user = self._create_user(username='test') + is_valid = email_token_generator.check_token(test_user, token) + self.assertFalse(is_valid) + + with self.subTest('Token invalidated after password change'): + self.admin.set_password('new_password') + self.admin.save() + is_valid = email_token_generator.check_token(self.admin, token) + self.assertFalse(is_valid) + + def test_email_unsubscribe_view(self): + unsubscribe_link_generated = generate_unsubscribe_link(self.admin, False) + token = unsubscribe_link_generated.split('?token=')[1] + local_unsubscribe_url = reverse('notifications:unsubscribe') + unsubscribe_url = f"{local_unsubscribe_url}?token={token}" + + with self.subTest('Test GET request with valid token'): + response = self.client.get(unsubscribe_url) + self.assertEqual(response.status_code, 200) + + with self.subTest('Test POST request with valid token'): + response = self.client.post(unsubscribe_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['message'], 'Successfully unsubscribed') + + with self.subTest('Test GET request with invalid token'): + response = self.client.get(f"{local_unsubscribe_url}?token=invalid_token") + self.assertContains(response, 'Invalid or Expired Link') + self.assertEqual(response.status_code, 200) + + with self.subTest('Test POST request with invalid token'): + response = self.client.post(f"{local_unsubscribe_url}?token=invalid_token") + self.assertEqual(response.status_code, 400) + + with self.subTest('Test GET request with invalid user'): + tester = self._create_user(username='tester') + tester_link_generated = generate_unsubscribe_link(tester) + token = tester_link_generated.split('?token=')[1] + tester_unsubscribe_url = f"{local_unsubscribe_url}?token={token}" + tester.delete() + response = self.client.get(tester_unsubscribe_url) + self.assertContains(response, 'Invalid or Expired Link') + self.assertEqual(response.status_code, 200) + + with self.subTest('Test POST request with invalid user'): + tester = self._create_user(username='tester') + tester_link_generated = generate_unsubscribe_link(tester) + token = tester_link_generated.split('?token=')[1] + tester_unsubscribe_url = f"{local_unsubscribe_url}?token={token}" + tester.delete() + response = self.client.post(tester_unsubscribe_url) + self.assertEqual(response.status_code, 400) + + with self.subTest('Test GET request with no token'): + response = self.client.get(local_unsubscribe_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Invalid or Expired Link') + self.assertFalse(response.context['valid']) + + with self.subTest('Test POST request with no token'): + response = self.client.post(local_unsubscribe_url) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['message'], 'No token provided') + + with self.subTest('Test POST request with empty JSON body'): + response = self.client.post( + unsubscribe_url, content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['message'], 'Successfully unsubscribed') + + with self.subTest('Test POST request with subscribe=True in JSON'): + response = self.client.post( + unsubscribe_url, + data=json.dumps({'subscribe': True}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['message'], 'Successfully subscribed') + + with self.subTest('Test POST request with subscribe=False in JSON'): + response = self.client.post( + unsubscribe_url, + data=json.dumps({'subscribe': False}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['message'], 'Successfully unsubscribed') + + with self.subTest('Test POST request with invalid JSON'): + invalid_json = "{'data: invalid}" + response = self.client.post( + unsubscribe_url, + data=invalid_json, + content_type='application/json', + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['message'], 'Invalid JSON data') + + 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_selenium.py b/openwisp_notifications/tests/test_selenium.py new file mode 100644 index 00000000..74082a4b --- /dev/null +++ b/openwisp_notifications/tests/test_selenium.py @@ -0,0 +1,227 @@ +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +from openwisp_notifications.signals import notify +from openwisp_notifications.swapper import load_model, swapper_load_model +from openwisp_notifications.utils import _get_object_link, generate_unsubscribe_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 TestSelenium( + SeleniumTestMixin, + TestOrganizationMixin, + StaticLiveServerTestCase, +): + 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, + recipient=self.admin, + verb='Test Notification', + email_subject='Test Email subject', + action_object=self.operator, + target=self.operator, + type='default', + ) + + def _create_notification(self): + return notify.send(**self.notification_options) + + def test_notification_relative_link(self): + self.login() + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-notification-elem')) + ) + notification_elem = self.web_driver.find_element( + By.CLASS_NAME, 'ow-notification-elem' + ) + data_location_value = notification_elem.get_attribute('data-location') + self.assertEqual( + data_location_value, _get_object_link(notification, 'target', False) + ) + + def test_notification_dialog(self): + self.login() + self.notification_options.update( + {'message': 'Test Message', 'description': 'Test Description'} + ) + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) + ) + self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) + ) + dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') + self.assertEqual( + dialog.find_element(By.CLASS_NAME, 'ow-message-title').text, 'Test Message' + ) + self.assertEqual( + dialog.find_element(By.CLASS_NAME, 'ow-message-description').text, + 'Test Description', + ) + + def test_notification_dialog_open_button_visibility(self): + self.login() + self.notification_options.pop('target') + self.notification_options.update( + {'message': 'Test Message', 'description': 'Test Description'} + ) + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) + ) + self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) + ) + dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') + # This confirms the button is hidden + dialog.find_element(By.CSS_SELECTOR, '.ow-message-target-redirect.ow-hide') + + def test_email_unsubscribe_page(self): + unsubscribe_link = generate_unsubscribe_link(self.admin, False) + self.open(unsubscribe_link) + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, 'toggle-btn')) + ) + self.assertEqual( + self.web_driver.find_element(By.ID, 'toggle-btn').text, + 'Unsubscribe', + ) + + # Unsubscribe + self.web_driver.find_element(By.ID, 'toggle-btn').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, 'confirmation-msg')) + ) + self.assertEqual( + self.web_driver.find_element(By.ID, 'confirmation-msg').text, + 'Successfully unsubscribed', + ) + self.assertEqual( + self.web_driver.find_element(By.ID, 'toggle-btn').text, + 'Subscribe', + ) + + # Re-subscribe + self.web_driver.find_element(By.ID, 'toggle-btn').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, 'confirmation-msg')) + ) + self.assertEqual( + self.web_driver.find_element(By.ID, 'confirmation-msg').text, + 'Successfully subscribed', + ) + self.assertEqual( + self.web_driver.find_element(By.ID, 'toggle-btn').text, + 'Unsubscribe', + ) + + def test_notification_preference_page(self): + self.login() + self.open('/notifications/preferences/') + + WebDriverWait(self.web_driver, 30).until( + EC.visibility_of_element_located( + (By.CLASS_NAME, 'global-settings-container') + ) + ) + + # Uncheck the global web checkbox + global_web_dropdown_toggle = WebDriverWait(self.web_driver, 30).until( + EC.element_to_be_clickable( + ( + By.CSS_SELECTOR, + '.global-setting-dropdown[data-web-state] .global-setting-dropdown-toggle', + ) + ) + ) + global_web_dropdown_toggle.click() + + global_web_dropdown_menu = WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located( + ( + By.CSS_SELECTOR, + '.global-setting-dropdown[data-web-state] .global-setting-dropdown-menu-open', + ) + ) + ) + + dont_notify_on_web_option = global_web_dropdown_menu.find_element( + By.XPATH, './/li[normalize-space()="Don\'t Notify on Web"]' + ) + dont_notify_on_web_option.click() + + confirmation_modal = WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, 'confirmation-modal')) + ) + + confirm_button = confirmation_modal.find_element(By.ID, 'confirm') + confirm_button.click() + + all_checkboxes = self.web_driver.find_elements( + By.CSS_SELECTOR, 'input[type="checkbox"]' + ) + 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.ID, 'org-1-web')) + ) + org_level_web_checkbox.click() + + # Verify that all web checkboxes under org-1 are selected + web_checkboxes = self.web_driver.find_elements( + By.CSS_SELECTOR, 'input[id^="org-1-web-"]' + ) + for checkbox in web_checkboxes: + 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, 30).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/tests/test_widget.py b/openwisp_notifications/tests/test_widget.py deleted file mode 100644 index 48bebeca..00000000 --- a/openwisp_notifications/tests/test_widget.py +++ /dev/null @@ -1,95 +0,0 @@ -from django.contrib.staticfiles.testing import StaticLiveServerTestCase -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait - -from openwisp_notifications.signals import notify -from openwisp_notifications.swapper import load_model -from openwisp_notifications.utils import _get_object_link -from openwisp_users.tests.utils import TestOrganizationMixin -from openwisp_utils.test_selenium_mixins import SeleniumTestMixin - -Notification = load_model('Notification') - - -class TestWidget( - SeleniumTestMixin, - TestOrganizationMixin, - StaticLiveServerTestCase, -): - serve_static = True - - def setUp(self): - self.admin = self._create_admin( - username=self.admin_username, password=self.admin_password - ) - self.operator = super()._get_operator() - self.notification_options = dict( - sender=self.admin, - recipient=self.admin, - verb='Test Notification', - email_subject='Test Email subject', - action_object=self.operator, - target=self.operator, - type='default', - ) - - def _create_notification(self): - return notify.send(**self.notification_options) - - def test_notification_relative_link(self): - self.login() - notification = self._create_notification().pop()[1][0] - self.web_driver.find_element(By.ID, 'openwisp_notifications').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.CLASS_NAME, 'ow-notification-elem')) - ) - notification_elem = self.web_driver.find_element( - By.CLASS_NAME, 'ow-notification-elem' - ) - data_location_value = notification_elem.get_attribute('data-location') - self.assertEqual( - data_location_value, _get_object_link(notification, 'target', False) - ) - - def test_notification_dialog(self): - self.login() - self.notification_options.update( - {'message': 'Test Message', 'description': 'Test Description'} - ) - notification = self._create_notification().pop()[1][0] - self.web_driver.find_element(By.ID, 'openwisp_notifications').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) - ) - self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) - ) - dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') - self.assertEqual( - dialog.find_element(By.CLASS_NAME, 'ow-message-title').text, 'Test Message' - ) - self.assertEqual( - dialog.find_element(By.CLASS_NAME, 'ow-message-description').text, - 'Test Description', - ) - - def test_notification_dialog_open_button_visibility(self): - self.login() - self.notification_options.pop('target') - self.notification_options.update( - {'message': 'Test Message', 'description': 'Test Description'} - ) - notification = self._create_notification().pop()[1][0] - self.web_driver.find_element(By.ID, 'openwisp_notifications').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) - ) - self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) - ) - dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') - # This confirms the button is hidden - dialog.find_element(By.CSS_SELECTOR, '.ow-message-target-redirect.ow-hide') diff --git a/openwisp_notifications/tokens.py b/openwisp_notifications/tokens.py new file mode 100644 index 00000000..53da6b83 --- /dev/null +++ b/openwisp_notifications/tokens.py @@ -0,0 +1,58 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.crypto import constant_time_compare +from django.utils.http import base36_to_int + + +class EmailTokenGenerator(PasswordResetTokenGenerator): + """ + Email token generator that extends the default PasswordResetTokenGenerator + without a fixed expiry period. + """ + + key_salt = "openwisp_notifications.tokens.EmailTokenGenerator" + + def check_token(self, user, token): + """ + Check that a token is correct for a given user. + """ + if not (user and token): + return False + + # Parse the token + try: + ts_b36, _ = token.split("-") + except ValueError: + return False + + try: + ts = base36_to_int(ts_b36) + except ValueError: + return False + + # Check that the timestamp/uid has not been tampered with + if hasattr(self, 'secret_fallbacks'): + # For newer Django versions + for secret in [self.secret, *self.secret_fallbacks]: + if constant_time_compare( + self._make_token_with_timestamp(user, ts, secret), + token, + ): + return True + else: + # For older Django versions + if constant_time_compare(self._make_token_with_timestamp(user, ts), token): + return True + + return False + + def _make_hash_value(self, user, timestamp): + """ + Hash the user's primary key and password to produce a token that is + invalidated when the password is reset. + """ + email_field = user.get_email_field_name() + email = getattr(user, email_field, "") or "" + return f"{user.pk}{user.password}{timestamp}{email}" + + +email_token_generator = EmailTokenGenerator() diff --git a/openwisp_notifications/urls.py b/openwisp_notifications/urls.py index efc6d2ba..c303c3a1 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_view, unsubscribe_view def get_urls(api_views=None, social_views=None): @@ -10,7 +11,18 @@ 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_view, + name='notification_preference', + ), + path( + 'notifications/user//preferences/', + notification_preference_view, + name='user_notification_preference', + ), + path('notifications/unsubscribe/', unsubscribe_view, name='unsubscribe'), ] return urls diff --git a/openwisp_notifications/utils.py b/openwisp_notifications/utils.py index 1edecde7..31080695 100644 --- a/openwisp_notifications/utils.py +++ b/openwisp_notifications/utils.py @@ -1,6 +1,17 @@ +import json + from django.conf import settings from django.contrib.sites.models import Site from django.urls import NoReverseMatch, reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ + +from openwisp_notifications.exceptions import NotificationRenderException +from openwisp_utils.admin_theme.email import send_email + +from .tokens import email_token_generator def _get_object_link(obj, field, absolute_url=False, *args, **kwargs): @@ -28,3 +39,56 @@ def normalize_unread_count(unread_count): return '99+' else: return unread_count + + +def send_notification_email(notification): + """Send a single email notification""" + try: + subject = notification.email_subject + except NotificationRenderException: + # Do not send email if notification is malformed. + return + url = notification.data.get('url', '') if notification.data else None + description = notification.message + if url: + target_url = url + elif notification.target: + target_url = notification.redirect_view_url + else: + target_url = None + if target_url: + description += _('\n\nFor more information see %(target_url)s.') % { + 'target_url': target_url + } + + unsubscribe_link = generate_unsubscribe_link(notification.recipient) + + send_email( + subject, + description, + notification.email_message, + recipients=[notification.recipient.email], + extra_context={ + 'call_to_action_url': target_url, + 'call_to_action_text': _('Find out more'), + 'footer': mark_safe( + 'To unsubscribe from these notifications, ' + f'click here.' + ), + }, + headers={ + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': f'<{unsubscribe_link}>', + }, + ) + + +def generate_unsubscribe_link(user, full_url=True): + token = email_token_generator.make_token(user) + data = json.dumps({'user_id': str(user.id), 'token': token}) + encoded_data = urlsafe_base64_encode(force_bytes(data)) + unsubscribe_url = reverse('notifications:unsubscribe') + if not full_url: + return f"{unsubscribe_url}?token={encoded_data}" + current_site = Site.objects.get_current() + return f"https://{current_site.domain}{unsubscribe_url}?token={encoded_data}" diff --git a/openwisp_notifications/views.py b/openwisp_notifications/views.py new file mode 100644 index 00000000..fe61b3cb --- /dev/null +++ b/openwisp_notifications/views.py @@ -0,0 +1,146 @@ +import base64 +import json + +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.http import Http404, JsonResponse +from django.shortcuts import render +from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.utils.http import urlsafe_base64_decode +from django.utils.translation import gettext as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import TemplateView + +from openwisp_notifications.swapper import load_model + +from .tokens import email_token_generator + +User = get_user_model() +NotificationSetting = load_model('NotificationSetting') + + +class NotificationPreferenceView(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.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 + + +@method_decorator(csrf_exempt, name='dispatch') +class UnsubscribeView(TemplateView): + template_name = 'openwisp_notifications/unsubscribe.html' + + def dispatch(self, request, *args, **kwargs): + self.encoded_token = request.GET.get('token') + if not self.encoded_token: + if request.method == 'POST': + return JsonResponse( + {'success': False, 'message': 'No token provided'}, status=400 + ) + return render(request, self.template_name, {'valid': False}) + + self.user, self.valid = self._validate_token(self.encoded_token) + if not self.valid: + if request.method == 'POST': + return JsonResponse( + {'success': False, 'message': 'Invalid or expired token'}, + status=400, + ) + return render(request, self.template_name, {'valid': False}) + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + is_subscribed = self.get_user_preference(self.user) if self.valid else False + context.update( + { + 'valid': self.valid, + 'is_subscribed': is_subscribed, + } + ) + return context + + def post(self, request, *args, **kwargs): + try: + if request.content_type == 'application/json': + data = json.loads(request.body) + subscribe = data.get('subscribe', False) is True + else: + # Unsubscribe by default + subscribe = False + except json.JSONDecodeError: + return JsonResponse( + {'success': False, 'message': 'Invalid JSON data'}, status=400 + ) + + self.update_user_preferences(self.user, subscribe) + status_message = 'subscribed' if subscribe else 'unsubscribed' + return JsonResponse( + {'success': True, 'message': f'Successfully {status_message}'} + ) + + def _validate_token(self, encoded_token): + try: + decoded_data = urlsafe_base64_decode(encoded_token).decode('utf-8') + data = json.loads(decoded_data) + user_id = data.get('user_id') + token = data.get('token') + + user = User.objects.get(id=user_id) + if email_token_generator.check_token(user, token): + return user, True + except ( + User.DoesNotExist, + ValueError, + json.JSONDecodeError, + base64.binascii.Error, + ): + pass + + return None, False + + def get_user_preference(self, user): + """ + Check if any of the user's notification settings have email notifications enabled. + """ + return NotificationSetting.objects.filter(user=user, email=True).exists() + + def update_user_preferences(self, user, subscribe): + """ + Update all of the user's notification settings to set email preference. + """ + NotificationSetting.objects.filter(user=user).update(email=subscribe) + + +notification_preference_view = NotificationPreferenceView.as_view() +unsubscribe_view = UnsubscribeView.as_view() 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", + ), + ), + ] diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 89394685..291c3392 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -27,6 +27,7 @@ 'django.contrib.staticfiles', 'django.contrib.sites', 'django_extensions', + 'openwisp_users.accounts', 'allauth', 'allauth.account', 'allauth.socialaccount',