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] Add unsubscribe link to email notifications #307

Open
wants to merge 22 commits into
base: notification-preferences
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
002dddf
[chore] Create EmailTokenGenerator
Dhanus3133 Aug 21, 2024
7322a73
[chore] Unsubscribe Implementation
Dhanus3133 Aug 24, 2024
6290e16
[chore] Remove token time expiry
Dhanus3133 Sep 1, 2024
cfff9aa
[chore] Handle logic for any one email setting type enabled even when…
Dhanus3133 Sep 1, 2024
286b22b
[chore] Translatable i18n and js file refactor
Dhanus3133 Sep 1, 2024
b82f441
[chore] Add tests
Dhanus3133 Sep 1, 2024
ede83b3
Merge branch 'gsoc24-rebased' into feat/manage-notifications-unsubscribe
Dhanus3133 Sep 1, 2024
21b9362
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Sep 1, 2024
3b96255
[chore] Handle check_token function for older django version
Dhanus3133 Sep 1, 2024
27952c7
[ci] Add notification-preferences target branches PR to build actions
Dhanus3133 Sep 1, 2024
6ebb734
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Sep 3, 2024
90a6056
[chore] Bump changes
Dhanus3133 Sep 5, 2024
735c4d0
[chore] Add tests
Dhanus3133 Sep 5, 2024
7528ed7
[chore] Add selenium tests
Dhanus3133 Sep 6, 2024
b37fce5
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Sep 7, 2024
2f802be
[chore] Bump changes
Dhanus3133 Sep 7, 2024
5c9df62
[fix] JS console
Dhanus3133 Sep 7, 2024
8b46ca7
[fix] Tests
Dhanus3133 Sep 7, 2024
35ea832
[chore] Reuse base_entrance.html
Dhanus3133 Sep 11, 2024
dfe62ed
Merge branch 'notification-preferences' into feat/manage-notification…
Dhanus3133 Oct 2, 2024
4171bd4
[chore] CSS changes
Dhanus3133 Oct 2, 2024
2afa9fe
[qa] Fixes
Dhanus3133 Oct 2, 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
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
- master
- dev
- gsoc24-rebased
- notification-preferences

jobs:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
body,
html {
height: 100%;
margin: 0;
background-color: #f0f4f8;
}
.container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100vh;
}
.content {
background: #ffffff;
padding: 40px;
border-radius: 12px;
text-align: center;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 100%;
}
.logo {
width: 150px;
margin-bottom: 20px;
}
.icon {
font-size: 56px;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
}
p {
color: #555;
margin-bottom: 20px;
line-height: 1.5;
}
button {
background-color: #333;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
opacity: 0.7;
border-color: #333;
}
.footer {
margin-top: 20px;
}
#confirmation-msg {
color: green;
margin-top: 20px;
font-weight: bold;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use strict';
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
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');
confirmationMsg.textContent = data.message;
} else {
statusMessage.textContent = gettext('You are currently unsubscribed from notifications.');
toggleBtn.textContent = gettext('Subscribe');
confirmationMsg.textContent = data.message;
}

confirmationMsg.style.display = 'block';
} else {
window.alert(data.message);
}
});
}

document.addEventListener('DOMContentLoaded', function() {
const toggleBtn = document.getElementById('toggle-btn');
toggleBtn.addEventListener('click', function() {
const currentStatus = toggleBtn.textContent.trim().toLowerCase();
const subscribe = currentStatus === gettext('subscribe').toLowerCase();
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
updateSubscription(subscribe);
});
});
23 changes: 20 additions & 3 deletions openwisp_notifications/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
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 send_notification_email
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

Expand Down Expand Up @@ -273,17 +277,26 @@ def send_batched_email_notifications(instance_id):
.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,
}

extra_context = {}
unsubscribe_link = generate_unsubscribe_link(user)

extra_context = {
'footer': mark_safe(
'To unsubscribe from these notifications, '
f'<a href="{unsubscribe_link}">click here</a>.'
),
}
if notifications_count > display_limit:
extra_context = {
'call_to_action_url': f"https://{current_site.domain}/admin/#notifications",
'call_to_action_url': f'https://{current_site.domain}/admin/#notifications',
'call_to_action_text': _('View all Notifications'),
}
context.update(extra_context)
Expand All @@ -298,6 +311,10 @@ def send_batched_email_notifications(instance_id):
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)
Expand Down
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{% load static %}
{% load i18n %}

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% trans 'Manage Subscription Preferences' %}</title>
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/openwisp.css' %}" />
<link rel="icon" type="image/x-icon" href="{% static 'ui/openwisp/images/favicon.png' %}" />
<link rel="stylesheet" type="text/css" href="{% static 'openwisp-notifications/css/unsubscribe.css' %}" />
</head>
<body>
<div class="container">
<img
src="{% static 'ui/openwisp/images/openwisp-logo-black.svg' %}"
alt="{% trans 'OpenWISP Logo' %}"
class="logo"
/>
<div class="content">
<div class="icon">✉️</div>
<h1>{% trans 'Manage Notification Preferences' %}</h1>
{% if valid %}
<p id="status-message">
{% if is_subscribed %}
{% trans 'You are currently subscribed to notifications.' %}
{% else %}
{% trans 'You are currently unsubscribed from notifications.' %}
{% endif %}
</p>
<button id="toggle-btn">
{% if is_subscribed %}
{% trans 'Unsubscribe' %}
{% else %}
{% trans 'Subscribe' %}
{% endif %}
</button>
<p id="confirmation-msg" style="display:none;"></p>
{% else %}
<h1>{% trans 'Invalid or Expired Link' %}</h1>
<p>{% trans 'The link you used is invalid or expired.' %}</p>
{% endif %}
<div class="footer">
<p>
{% trans 'Manage Other Preferences?' %} <a href="{% url 'notifications:notification_preference' %}">{% trans 'Click here' %}</a>
</p>
</div>
</div>
</div>
<script src="{% static 'openwisp-notifications/js/unsubscribe.js' %}"></script>
</body>
</html>
111 changes: 110 additions & 1 deletion openwisp_notifications/tests/test_notifications.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta
from unittest.mock import patch
from uuid import uuid4
Expand Down Expand Up @@ -31,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

Expand Down Expand Up @@ -1053,6 +1055,113 @@ 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):
Dhanus3133 marked this conversation as resolved.
Show resolved Hide resolved
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')
Expand Down
Loading