feat: add a command to fetch unsubscribed emails from Braze
This commit is contained in:
committed by
Shahbaz Shabbir
parent
51c826f41d
commit
f6071490e8
@@ -0,0 +1,123 @@
|
||||
"""Management command to retrieve unsubscribed emails from Braze."""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.message import EmailMultiAlternatives
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.template.loader import get_template
|
||||
|
||||
from lms.djangoapps.utils import get_braze_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
This management command retrieves unsubscribed emails from Braze, saves these emails into a CSV file, and
|
||||
then sends the file to the email address specified in the BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL setting.
|
||||
"""
|
||||
|
||||
help = """
|
||||
To retrieve unsubscribed emails from the Braze API, we need to specify a start and end date. If either of
|
||||
the dates is not provided, we will automatically retrieve the data for the previous week and replace the
|
||||
missing date value.
|
||||
|
||||
Usage:
|
||||
python manage.py retrieve_unsubscribed_emails [--start_date START_DATE] [--end_date END_DATE]
|
||||
|
||||
Options:
|
||||
--start_date START_DATE Start date (optional)
|
||||
--end_date END_DATE End date (optional)
|
||||
|
||||
Example:
|
||||
$ ... retrieve_unsubscribed_emails --start_date 2022-01-01 --end_date 2023-01-01
|
||||
"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--start_date', dest='start_date', help='Start date')
|
||||
parser.add_argument('--end_date', dest='end_date', help='End date')
|
||||
|
||||
def _write_csv(self, csv, data):
|
||||
"""
|
||||
Helper method to write data into CSV
|
||||
"""
|
||||
headers = list(data[0].keys())
|
||||
csv.write(','.join(headers).encode('utf-8') + b"\n")
|
||||
|
||||
for row in data:
|
||||
values = [str(row[key]) for key in headers]
|
||||
csv.write(','.join(values).encode('utf-8') + b"\n")
|
||||
|
||||
csv.seek(0)
|
||||
logger.info('Write unsubscribed emails data into CSV file successfully.')
|
||||
return csv
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Execute the command
|
||||
"""
|
||||
start_date = options.get('start_date')
|
||||
end_date = options.get('end_date')
|
||||
|
||||
if not start_date or not end_date:
|
||||
start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
logger.info(f'Retrieving unsubscribed emails from {start_date} to {end_date}')
|
||||
|
||||
try:
|
||||
braze_client = get_braze_client()
|
||||
if not braze_client:
|
||||
logger.info('No Braze client found. Unable to retrieve unsubscribed emails.')
|
||||
return
|
||||
|
||||
emails = braze_client.retrieve_unsubscribed_emails(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
if not emails:
|
||||
logger.info(f'No unsubscribed emails found between {start_date} - {end_date}.')
|
||||
return
|
||||
|
||||
logger.info('Email addresses for users that unsubscribed from emails between '
|
||||
f'{start_date} - {end_date} retrieved successfully from Braze')
|
||||
|
||||
context = {
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
}
|
||||
subject = f'Unsubscribed Emails from {start_date} to {end_date}'
|
||||
txt_template = 'unsubscribed_emails/email/body.txt'
|
||||
html_template = 'unsubscribed_emails/email/body.html'
|
||||
|
||||
template = get_template(txt_template)
|
||||
plain_content = template.render(context)
|
||||
template = get_template(html_template)
|
||||
html_content = template.render(context)
|
||||
|
||||
email_msg = EmailMultiAlternatives(
|
||||
subject,
|
||||
plain_content,
|
||||
settings.BRAZE_UNSUBSCRIBED_EMAILS_FROM_EMAIL,
|
||||
settings.BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL
|
||||
)
|
||||
email_msg.attach_alternative(html_content, 'text/html')
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='wb', suffix='.csv') as csv_file:
|
||||
csv_file = self._write_csv(csv_file, emails)
|
||||
csv_file_path = csv_file.name
|
||||
with open(csv_file_path, 'rb') as file:
|
||||
email_msg.attach(filename='unsubscribed_emails.csv', content=file.read(), mimetype='text/csv')
|
||||
|
||||
email_msg.send()
|
||||
logger.info(
|
||||
f'Unsubscribed emails data sent successfully to {settings.BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL}')
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(f'Unable to retrieve unsubscribed emails from Braze due to exception: {exc}')
|
||||
raise CommandError(
|
||||
f'Unable to retrieve unsubscribed emails from Braze due to exception: {exc}'
|
||||
) from exc
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for retrieve unsubscribed emails management command"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import call
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
|
||||
class RetrieveUnsubscribedEmailsTests(TestCase):
|
||||
"""
|
||||
Tests for the retrieve_unsubscribed_emails command.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
|
||||
self.end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
@staticmethod
|
||||
def _write_test_csv(csv, lines):
|
||||
"""
|
||||
Write a test csv file with the lines provided
|
||||
"""
|
||||
csv.write(b"email,unsubscribed_at\n")
|
||||
for line in lines:
|
||||
csv.write(six.b(line))
|
||||
csv.seek(0)
|
||||
return csv
|
||||
|
||||
@override_settings(
|
||||
BRAZE_UNSUBSCRIBED_EMAILS_FROM_EMAIL='test@example.com',
|
||||
BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL=['test@example.com']
|
||||
)
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.info')
|
||||
def test_retrieve_unsubscribed_emails_command(self, mock_logger_info, mock_get_braze_client, mock_send):
|
||||
"""
|
||||
Test the retrieve_unsubscribed_emails command
|
||||
"""
|
||||
mock_braze_client = mock_get_braze_client.return_value
|
||||
mock_braze_client.retrieve_unsubscribed_emails.return_value = [
|
||||
{'email': 'test1@example.com', 'unsubscribed_at': '2023-06-01 10:00:00'},
|
||||
{'email': 'test2@example.com', 'unsubscribed_at': '2023-06-02 12:00:00'},
|
||||
]
|
||||
mock_send.return_value = MagicMock()
|
||||
|
||||
call_command('retrieve_unsubscribed_emails')
|
||||
|
||||
mock_logger_info.assert_has_calls([
|
||||
call(f'Retrieving unsubscribed emails from {self.start_date} to {self.end_date}'),
|
||||
call('Email addresses for users that unsubscribed from emails between '
|
||||
f'{self.start_date} - {self.end_date} retrieved successfully from Braze'),
|
||||
call('Write unsubscribed emails data into CSV file successfully.'),
|
||||
call(f'Unsubscribed emails data sent successfully to {settings.BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL}')
|
||||
])
|
||||
mock_send.assert_called_once()
|
||||
|
||||
with NamedTemporaryFile() as csv:
|
||||
filepath = csv.name
|
||||
lines = [
|
||||
'test1@example.com,2023-06-01 10:00:00',
|
||||
'test2@example.com,2023-06-02 12:00:00'
|
||||
]
|
||||
self._write_test_csv(csv, lines)
|
||||
|
||||
with open(filepath, 'r') as csv_file:
|
||||
csv_data = csv_file.read()
|
||||
self.assertIn('test1@example.com,2023-06-01 10:00:00', csv_data)
|
||||
self.assertIn('test2@example.com,2023-06-02 12:00:00', csv_data)
|
||||
|
||||
@override_settings(
|
||||
BRAZE_UNSUBSCRIBED_EMAILS_FROM_EMAIL='test@example.com',
|
||||
BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL=['test@example.com']
|
||||
)
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.info')
|
||||
def test_retrieve_unsubscribed_emails_command_with_dates(self, mock_logger_info, mock_get_braze_client, mock_send):
|
||||
"""
|
||||
Test the retrieve_unsubscribed_emails command with custom start and end dates.
|
||||
"""
|
||||
mock_braze_client = mock_get_braze_client.return_value
|
||||
mock_braze_client.retrieve_unsubscribed_emails.return_value = [
|
||||
{'email': 'test3@example.com', 'unsubscribed_at': '2023-06-03 08:00:00'},
|
||||
{'email': 'test4@example.com', 'unsubscribed_at': '2023-06-04 14:00:00'},
|
||||
]
|
||||
mock_send.return_value = MagicMock()
|
||||
|
||||
call_command(
|
||||
'retrieve_unsubscribed_emails',
|
||||
'--start_date', self.start_date,
|
||||
'--end_date', self.end_date,
|
||||
)
|
||||
|
||||
mock_logger_info.assert_has_calls([
|
||||
call(f'Retrieving unsubscribed emails from {self.start_date} to {self.end_date}'),
|
||||
call('Email addresses for users that unsubscribed from emails between '
|
||||
f'{self.start_date} - {self.end_date} retrieved successfully from Braze'),
|
||||
call('Write unsubscribed emails data into CSV file successfully.'),
|
||||
call(f'Unsubscribed emails data sent successfully to {settings.BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL}')
|
||||
])
|
||||
mock_send.assert_called_once()
|
||||
|
||||
with NamedTemporaryFile() as csv:
|
||||
filepath = csv.name
|
||||
lines = [
|
||||
'test3@example.com,2023-06-03 08:00:00',
|
||||
'test4@example.com,2023-06-04 14:00:00'
|
||||
]
|
||||
self._write_test_csv(csv, lines)
|
||||
|
||||
with open(filepath, 'r') as csv_file:
|
||||
csv_data = csv_file.read()
|
||||
self.assertIn('test3@example.com,2023-06-03 08:00:00', csv_data)
|
||||
self.assertIn('test4@example.com,2023-06-04 14:00:00', csv_data)
|
||||
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.exception')
|
||||
def test_retrieve_unsubscribed_emails_command_braze_exception(self, mock_logger_exception, mock_get_braze_client,
|
||||
mock_send):
|
||||
"""
|
||||
Test the retrieve_unsubscribed_emails command when an exception is raised.
|
||||
"""
|
||||
mock_braze_client = mock_get_braze_client.return_value
|
||||
mock_braze_client.retrieve_unsubscribed_emails.side_effect = Exception('Braze API error')
|
||||
mock_send.return_value = MagicMock()
|
||||
|
||||
with self.assertRaises(CommandError):
|
||||
call_command('retrieve_unsubscribed_emails')
|
||||
|
||||
mock_logger_exception.assert_called_once_with(
|
||||
'Unable to retrieve unsubscribed emails from Braze due to exception: Braze API error'
|
||||
)
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.info')
|
||||
def test_retrieve_unsubscribed_emails_command_no_data(self, mock_logger_info, mock_get_braze_client, mock_send):
|
||||
"""
|
||||
Test the retrieve_unsubscribed_emails command when no unsubscribed emails are returned.
|
||||
"""
|
||||
mock_braze_client = mock_get_braze_client.return_value
|
||||
mock_braze_client.retrieve_unsubscribed_emails.return_value = []
|
||||
mock_send.return_value = MagicMock()
|
||||
|
||||
call_command('retrieve_unsubscribed_emails')
|
||||
|
||||
mock_logger_info.assert_has_calls([
|
||||
call(f'Retrieving unsubscribed emails from {self.start_date} to {self.end_date}'),
|
||||
call(f'No unsubscribed emails found between {self.start_date} - {self.end_date}.'),
|
||||
])
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@override_settings(
|
||||
BRAZE_UNSUBSCRIBED_EMAILS_FROM_EMAIL='test@example.com',
|
||||
BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL=['test@example.com']
|
||||
)
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client')
|
||||
@patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.exception')
|
||||
def test_retrieve_unsubscribed_emails_command_error_sending_email(self, mock_logger_exception,
|
||||
mock_get_braze_client, mock_send):
|
||||
"""
|
||||
Test the retrieve_unsubscribed_emails command when an error occurs during email sending.
|
||||
"""
|
||||
mock_braze_client = mock_get_braze_client.return_value
|
||||
mock_braze_client.retrieve_unsubscribed_emails.return_value = [
|
||||
{'email': 'test1@example.com', 'unsubscribed_at': '2023-06-01 10:00:00'},
|
||||
]
|
||||
mock_send.side_effect = Exception('Email sending error')
|
||||
|
||||
with self.assertRaises(CommandError):
|
||||
call_command('retrieve_unsubscribed_emails')
|
||||
|
||||
mock_logger_exception.assert_called_once_with(
|
||||
'Unable to retrieve unsubscribed emails from Braze due to exception: Email sending error'
|
||||
)
|
||||
mock_send.assert_called_once()
|
||||
@@ -0,0 +1,14 @@
|
||||
{% load i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE }}">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body style="font-family:Arial,'Helvetica Neue',Helvetica,sans-serif;font-size:14px;line-height:150%;margin:auto">
|
||||
|
||||
{% block body %}
|
||||
{% endblock body %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "unsubscribed_emails/email/base.html" %}
|
||||
{% block body %}
|
||||
|
||||
<p>
|
||||
Please find attached CSV file for Unsubscribed Emails from <strong>{{ start_date }}</strong> to <strong>{{ end_date }}</strong>
|
||||
</p>
|
||||
|
||||
{% endblock body %}
|
||||
@@ -0,0 +1 @@
|
||||
Please find attached CSV file for Unsubscribed Emails from {{ start_date }} to {{ end_date }}
|
||||
Reference in New Issue
Block a user