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 77 commits into
base: gsoc24-rebased
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
04be6f9
[chore] REST API changes
Dhanus3133 Jul 9, 2024
23ff78a
[chore] Add tests
Dhanus3133 Jul 14, 2024
b83059e
[chore] Use GenericAPIView
Dhanus3133 Jul 17, 2024
ff375af
[refactor] URL routes
Dhanus3133 Jul 17, 2024
beb8500
[chore] Update tests
Dhanus3133 Jul 19, 2024
7b7eda0
[chore] Add Global Notification
Dhanus3133 Jul 19, 2024
7bf80a5
[chore] Global Notification Preference changes
Dhanus3133 Jul 25, 2024
da95d6e
[feat] Notification Settings Page
Dhanus3133 Aug 1, 2024
ed4b568
[qa] Check fixes
Dhanus3133 Aug 2, 2024
512041e
[chore] Notification settings page with breadcrumbs
Dhanus3133 Aug 3, 2024
b773092
[chore] Notification settings update
Dhanus3133 Aug 4, 2024
c789d43
[chore] CSS updates
Dhanus3133 Aug 5, 2024
9de0b51
[chore] Bump changes
Dhanus3133 Aug 6, 2024
55a1146
[fix] Verbose errora admin user page
Dhanus3133 Aug 6, 2024
ef1ab62
[chore] Web notification on the left, email on the right
Dhanus3133 Aug 6, 2024
07ad539
[chore] Handling auto trigger of email without web checkboxes
Dhanus3133 Aug 10, 2024
77594d6
[chore] Automatically open first org dropdown, label for org level em…
Dhanus3133 Aug 10, 2024
c75a890
[chore] Fetch current user global setting preference and make it default
Dhanus3133 Aug 10, 2024
3fdd806
[chore] Update type_label for user-setting api
Dhanus3133 Aug 10, 2024
78ec0f0
[chore] Reduced CSS and replicate admin design
Dhanus3133 Aug 11, 2024
e828707
[chore] Update org name styles and add no setting available
Dhanus3133 Aug 11, 2024
f88e48e
[QA] Fixes
Dhanus3133 Aug 11, 2024
f7f69d1
[chore] Toast dialog update
Dhanus3133 Aug 12, 2024
d0091e6
[chore] Variable naming
Dhanus3133 Aug 12, 2024
667a4f2
[chore] Org level email/web center to the checkbox
Dhanus3133 Aug 12, 2024
3eec3c6
[fix] Tests
Dhanus3133 Aug 12, 2024
e6afe62
[chore] Reviewed change bump
Dhanus3133 Aug 16, 2024
0788512
[chore] Add tooltips
Dhanus3133 Aug 17, 2024
e06c38b
[chore] Remove unused
Dhanus3133 Aug 17, 2024
144065e
[chore] Add link in notification widget
Dhanus3133 Aug 19, 2024
fd9403e
[chore] Fix settings button in widget
Dhanus3133 Aug 20, 2024
907705f
[chore] Drop ow-show-unread button functionality
Dhanus3133 Aug 20, 2024
c6cca10
[chore] Handle UI on API errors
Dhanus3133 Aug 20, 2024
8cd948a
[chore] Remove unused
Dhanus3133 Aug 20, 2024
cbcce9d
[chore] Immediately apply setting changes and rollback on API error
Dhanus3133 Aug 21, 2024
f4c2661
[chore] Use switch
Dhanus3133 Aug 21, 2024
0ed9a80
[chore] Bump reviewed changes
Dhanus3133 Aug 21, 2024
7c2dd52
[chore] Fetch upto 100 user-settings per api request
Dhanus3133 Aug 21, 2024
7cb8ae8
[chore] Add 'Organization:' on the header
Dhanus3133 Aug 21, 2024
4b03146
[chore] Increase spacing around email/web global switches
Dhanus3133 Aug 21, 2024
246f53d
[chore] Add space around settings
Dhanus3133 Aug 28, 2024
b2542e1
[docs] Update Notification Preference page
Dhanus3133 Aug 31, 2024
8844a31
[chores] Small UI and view improvements
nemesifier Sep 2, 2024
6e1d86c
[chore] Bump review changes
Dhanus3133 Sep 3, 2024
48d34d8
[qa] Fix checks
Dhanus3133 Sep 3, 2024
d0dec10
[fix] URL update
Dhanus3133 Sep 3, 2024
8a7b283
[chore] Add test for missing notification_preference get api
Dhanus3133 Sep 3, 2024
6e110a0
[chore] Add missing tests
Dhanus3133 Sep 3, 2024
c229c0c
[chore] Bump changes
Dhanus3133 Sep 3, 2024
0894ac5
[chore] View Notification preferences button in user admin model for …
Dhanus3133 Sep 6, 2024
01cf662
[chore] Show loader
Dhanus3133 Sep 6, 2024
bded33a
[chore] Add selenium test
Dhanus3133 Sep 6, 2024
93b3097
[fix] Tests
Dhanus3133 Sep 6, 2024
fb9d02d
[fix] Tests
Dhanus3133 Sep 6, 2024
7a29833
[fix] Tests
Dhanus3133 Sep 6, 2024
3a1e358
[fix] Tests
Dhanus3133 Sep 7, 2024
65ec77a
[fix] Should fix Tests
Dhanus3133 Sep 7, 2024
ca97dfe
[chore] Bump changes
Dhanus3133 Sep 7, 2024
09b8544
[chore] Reuse serializer
Dhanus3133 Sep 7, 2024
ca34105
[chore] Increase waiting time for selenium tests
Dhanus3133 Sep 7, 2024
046b6b4
[chore] Update preference page org dropdown icon position
Dhanus3133 Sep 7, 2024
2364ae0
[fix] Error displaying __str__ for global notification setting
Dhanus3133 Sep 11, 2024
b745ba3
[chore] Verify global setting creation in test_post_migration_handler…
Dhanus3133 Sep 11, 2024
6b55c07
[chore] Remove transition on accordion
Dhanus3133 Sep 11, 2024
0ba70c7
[chore] Update tests
Dhanus3133 Sep 11, 2024
6879e7e
[chore] Bump changes
Dhanus3133 Sep 13, 2024
a238929
[fix] Import error
Dhanus3133 Sep 13, 2024
e13d7c3
[chore] Bump changes
Dhanus3133 Sep 17, 2024
fcda9e5
[chore] Update org level changes
Dhanus3133 Sep 20, 2024
1e04ce7
[chore] Use full_clean instead of saving directly
Dhanus3133 Sep 20, 2024
af65aa0
[chore] Update global notification setting changes
Dhanus3133 Sep 20, 2024
92c6668
[chore] UI changes
Dhanus3133 Sep 30, 2024
a5ffe4b
[changes] Improved JS and CSS
pandafy Dec 27, 2024
f06560b
[fix] Fixed bug in changing organization notification
pandafy Dec 27, 2024
da20e19
[tests] Added test for regression
pandafy Dec 27, 2024
0feb0d2
[chores] Increased padding for tooltip
pandafy Dec 27, 2024
6e320f8
[qa] Fixed formatting
pandafy Dec 27, 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'
)
16 changes: 16 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,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
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',
),
]
23 changes: 21 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_id=user_id)


class NotificationSettingListView(BaseNotificationSettingView, ListModelMixin):
Expand Down Expand Up @@ -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)
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
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()
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',
]
65 changes: 56 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,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
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
Loading