Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] Notification Preferences Page #290

Open
wants to merge 76 commits into
base: gsoc24-rebased
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 72 commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
6cc2835
[chore] REST API changes
Dhanus3133 Jul 9, 2024
7016f86
[chore] Add tests
Dhanus3133 Jul 14, 2024
6d410fc
[chore] Use GenericAPIView
Dhanus3133 Jul 17, 2024
e6cbb9c
[refactor] URL routes
Dhanus3133 Jul 17, 2024
531acda
[chore] Update tests
Dhanus3133 Jul 19, 2024
2dfc5a8
[chore] Add Global Notification
Dhanus3133 Jul 19, 2024
215118b
[chore] Global Notification Preference changes
Dhanus3133 Jul 25, 2024
90221ca
[feat] Notification Settings Page
Dhanus3133 Aug 1, 2024
64fe873
[ci] Run builds for gsoc24 branch
pandafy Jul 9, 2024
fdb8fdf
[qa] Check fixes
Dhanus3133 Aug 2, 2024
7c5cbeb
[chore] Notification settings page with breadcrumbs
Dhanus3133 Aug 3, 2024
dfca656
[chore] Notification settings update
Dhanus3133 Aug 4, 2024
0e5ebd2
[chore] CSS updates
Dhanus3133 Aug 5, 2024
44ca0dc
[chore] Bump changes
Dhanus3133 Aug 6, 2024
85bb0c9
[fix] Verbose errora admin user page
Dhanus3133 Aug 6, 2024
b264324
[chore] Web notification on the left, email on the right
Dhanus3133 Aug 6, 2024
b175fa1
[chore] Handling auto trigger of email without web checkboxes
Dhanus3133 Aug 10, 2024
c0d373f
[chore] Automatically open first org dropdown, label for org level em…
Dhanus3133 Aug 10, 2024
487f6dd
[chore] Fetch current user global setting preference and make it default
Dhanus3133 Aug 10, 2024
a5d8b4e
[chore] Update type_label for user-setting api
Dhanus3133 Aug 10, 2024
cb8a296
[chore] Reduced CSS and replicate admin design
Dhanus3133 Aug 11, 2024
4d5fca5
[chore] Update org name styles and add no setting available
Dhanus3133 Aug 11, 2024
5c6bf56
[QA] Fixes
Dhanus3133 Aug 11, 2024
8a8437f
[chore] Toast dialog update
Dhanus3133 Aug 12, 2024
8fb919c
[chore] Variable naming
Dhanus3133 Aug 12, 2024
7628892
[chore] Org level email/web center to the checkbox
Dhanus3133 Aug 12, 2024
946d743
[fix] Tests
Dhanus3133 Aug 12, 2024
1268fe6
[chore] Reviewed change bump
Dhanus3133 Aug 16, 2024
c8cfdf0
[chore] Add tooltips
Dhanus3133 Aug 17, 2024
f38dce3
[chore] Remove unused
Dhanus3133 Aug 17, 2024
cd40a29
[chore] Add link in notification widget
Dhanus3133 Aug 19, 2024
b99f801
[chore] Fix settings button in widget
Dhanus3133 Aug 20, 2024
3ecbd92
[chore] Drop ow-show-unread button functionality
Dhanus3133 Aug 20, 2024
33ec904
[chore] Handle UI on API errors
Dhanus3133 Aug 20, 2024
a0f08df
[chore] Remove unused
Dhanus3133 Aug 20, 2024
5a60f7d
[chore] Immediately apply setting changes and rollback on API error
Dhanus3133 Aug 21, 2024
852790b
[chore] Use switch
Dhanus3133 Aug 21, 2024
3caaeda
[chore] Bump reviewed changes
Dhanus3133 Aug 21, 2024
ec83771
[chore] Remove gsoc24 branch from builds
Dhanus3133 Aug 21, 2024
088abf2
[chore] Fetch upto 100 user-settings per api request
Dhanus3133 Aug 21, 2024
5f8e008
[chore] Add 'Organization:' on the header
Dhanus3133 Aug 21, 2024
1d4faae
[chore] Increase spacing around email/web global switches
Dhanus3133 Aug 21, 2024
aa813a7
Merge branch 'gsoc24-rebased' into notification-preferences
Dhanus3133 Aug 28, 2024
40471ba
[chore] Add space around settings
Dhanus3133 Aug 28, 2024
1e1978f
[docs] Update Notification Preference page
Dhanus3133 Aug 31, 2024
c912883
[chores] Small UI and view improvements
nemesifier Sep 2, 2024
afb3be9
Merge branch 'gsoc24-rebased' into notification-preferences
nemesifier Sep 2, 2024
3fc3533
[chore] Bump review changes
Dhanus3133 Sep 3, 2024
06bc604
[qa] Fix checks
Dhanus3133 Sep 3, 2024
72211c8
[fix] URL update
Dhanus3133 Sep 3, 2024
5f265be
[chore] Add test for missing notification_preference get api
Dhanus3133 Sep 3, 2024
7982603
[chore] Add missing tests
Dhanus3133 Sep 3, 2024
890d764
[chore] Bump changes
Dhanus3133 Sep 3, 2024
fcb9e23
[chore] View Notification preferences button in user admin model for …
Dhanus3133 Sep 6, 2024
6dd8ca5
[chore] Show loader
Dhanus3133 Sep 6, 2024
d1c5215
[chore] Add selenium test
Dhanus3133 Sep 6, 2024
082c967
[fix] Tests
Dhanus3133 Sep 6, 2024
c0b7357
[fix] Tests
Dhanus3133 Sep 6, 2024
6106f9b
[fix] Tests
Dhanus3133 Sep 6, 2024
234627e
[fix] Tests
Dhanus3133 Sep 7, 2024
3d53083
[fix] Should fix Tests
Dhanus3133 Sep 7, 2024
9661ae6
[chore] Bump changes
Dhanus3133 Sep 7, 2024
0ecaa74
[chore] Reuse serializer
Dhanus3133 Sep 7, 2024
4ec65b7
[chore] Increase waiting time for selenium tests
Dhanus3133 Sep 7, 2024
b9d62b4
[chore] Update preference page org dropdown icon position
Dhanus3133 Sep 7, 2024
baa6111
[fix] Error displaying __str__ for global notification setting
Dhanus3133 Sep 11, 2024
c10952e
[chore] Verify global setting creation in test_post_migration_handler…
Dhanus3133 Sep 11, 2024
7efa79e
[chore] Remove transition on accordion
Dhanus3133 Sep 11, 2024
0e687b4
[chore] Update tests
Dhanus3133 Sep 11, 2024
9156700
[chore] Bump changes
Dhanus3133 Sep 13, 2024
5413859
[fix] Import error
Dhanus3133 Sep 13, 2024
a796372
[chore] Bump changes
Dhanus3133 Sep 17, 2024
c3f01e5
[chore] Update org level changes
Dhanus3133 Sep 20, 2024
ad8ff56
[chore] Use full_clean instead of saving directly
Dhanus3133 Sep 20, 2024
6c53bf6
[chore] Update global notification setting changes
Dhanus3133 Sep 20, 2024
2836bbd
[chore] UI changes
Dhanus3133 Sep 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/user/notification-preferences.rst
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
22 changes: 0 additions & 22 deletions openwisp_notifications/admin.py
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 16 additions & 0 deletions openwisp_notifications/api/permissions.py
Original file line number Diff line number Diff line change
@@ -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'
)
10 changes: 10 additions & 0 deletions openwisp_notifications/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -87,3 +92,8 @@ class Meta:
'object_content_type',
'object_id',
]


class NotificationSettingUpdateSerializer(serializers.Serializer):
email = serializers.BooleanField()
web = serializers.BooleanField()
44 changes: 34 additions & 10 deletions openwisp_notifications/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<uuid:pk>/', views.notification_detail, name='notification_detail'),
path('notification/', views.notifications_list, name='notifications_list'),
nemesifier marked this conversation as resolved.
Show resolved Hide resolved
path(
'<uuid:pk>/redirect/',
'notification/read/',
views.notifications_read_all,
name='notifications_read_all',
),
path(
'notification/<uuid:pk>/',
views.notification_detail,
name='notification_detail',
),
path(
'notification/<uuid:pk>/redirect/',
views.notification_read_redirect,
name='notification_read_redirect',
),
path(
'user-setting/',
'user/<uuid:user_id>/user-setting/',
views.notification_setting_list,
name='notification_setting_list',
name='user_notification_setting_list',
),
path(
'user-setting/<uuid:pk>/',
'user/<uuid:user_id>/user-setting/<uuid:pk>/',
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/<str:app_label>/<str:model_name>/<uuid:object_id>/',
'notification/ignore/<str:app_label>/<str:model_name>/<uuid:object_id>/',
views.ignore_object_notification,
name='ignore_object_notification',
),
path(
'user/<uuid:user_id>/organization/<uuid:organization_id>/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/<uuid:pk>/',
views.notification_setting,
name='notification_setting',
),
]
36 changes: 34 additions & 2 deletions openwisp_notifications/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -198,11 +201,40 @@ def perform_create(self, serializer):
)


class OrganizationNotificationSettingView(GenericAPIView):
permission_classes = [IsAuthenticated, PreferencesPermission]
serializer_class = NotificationSettingUpdateSerializer

def post(self, request, user_id, organization_id):
notification_settings = NotificationSetting.objects.filter(
organization_id=organization_id, user_id=user_id
)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
validated_data = serializer.validated_data
web = validated_data.get('web')
email = validated_data.get('email')

for notification_setting in notification_settings:
if web and not email:
notification_setting.web = web
else:
notification_setting.web = web
notification_setting.email = email

NotificationSetting.objects.bulk_update(
notification_settings, ['web', 'email']
)
return Response(status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def post(self, request, user_id, organization_id):
notification_settings = NotificationSetting.objects.filter(
organization_id=organization_id, user_id=user_id
)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
validated_data = serializer.validated_data
web = validated_data.get('web')
email = validated_data.get('email')
for notification_setting in notification_settings:
if web and not email:
notification_setting.web = web
else:
notification_setting.web = web
notification_setting.email = email
NotificationSetting.objects.bulk_update(
notification_settings, ['web', 'email']
)
return Response(status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def post(self, request, user_id, organization_id):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
validated_data = serializer.validated_data
web = validated_data['web']
email = validated_data['email']
NotificationSetting.objects.filter(
organization_id=organization_id, user_id=user_id
).update(web=web, email=email)
return Response(status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

This way, we won't need to iterate over individual objects.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used validated_data['web'] instead of validated_data.get('web') because NotificationSettingUpdateSerializer requires web and email fields to be present in valid payload.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After another review, I came to conclusion that we don't need to make any changes to the serializer. Please make changes to the test for this endpoint as requested in my previous review. We will see if something breaks because this change.



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()
2 changes: 1 addition & 1 deletion openwisp_notifications/base/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ def get_queryset(self, request):
super()
.get_queryset(request)
.filter(deleted=False)
.exclude(organization=None)
.prefetch_related('organization')
)

class Media:
extends = True
js = [
'admin/js/jquery.init.js',
'openwisp-notifications/js/notification-settings.js',
]
57 changes: 48 additions & 9 deletions openwisp_notifications/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -277,21 +281,56 @@ 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:
original = self.__class__.objects.get(pk=self.pk)
updates = {'web': self.web}

# Update 'email' only if it's different from the previous state
if not self.web or self.email != original.email:
updates['email'] = self.email

self.user.notificationsetting_set.exclude(pk=self.pk).update(
**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
Expand Down
1 change: 1 addition & 0 deletions openwisp_notifications/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def send_email_notification(sender, instance, created, **kwargs):
return
# Get email preference of user for this type of notification.
target_org = getattr(getattr(instance, 'target', None), 'organization_id', None)

if instance.type and target_org:
try:
notification_setting = instance.recipient.notificationsetting_set.get(
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
float: right;
position: relative;
right: -2px;
bottom: -8px;
bottom: -3px;
background-size: 9px;
}
.ow-notification-toast.info .icon {
Expand Down Expand Up @@ -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;
Expand Down
Loading