feat: add a command to fetch unsubscribed emails from Braze

This commit is contained in:
Shahbaz Shabbir
2023-06-15 06:21:08 +05:00
committed by Shahbaz Shabbir
parent 51c826f41d
commit f6071490e8
5 changed files with 334 additions and 0 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
Please find attached CSV file for Unsubscribed Emails from {{ start_date }} to {{ end_date }}