From f6071490e8481b71fda0e74e5d6907a2e1157a83 Mon Sep 17 00:00:00 2001 From: Shahbaz Shabbir Date: Thu, 15 Jun 2023 06:21:08 +0500 Subject: [PATCH] feat: add a command to fetch unsubscribed emails from Braze --- .../commands/retrieve_unsubscribed_emails.py | 123 ++++++++++++ .../test_retrieve_unsubscribed_emails.py | 188 ++++++++++++++++++ .../unsubscribed_emails/email/base.html | 14 ++ .../unsubscribed_emails/email/body.html | 8 + .../unsubscribed_emails/email/body.txt | 1 + 5 files changed, 334 insertions(+) create mode 100644 common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py create mode 100644 common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py create mode 100644 common/djangoapps/student/templates/unsubscribed_emails/email/base.html create mode 100644 common/djangoapps/student/templates/unsubscribed_emails/email/body.html create mode 100644 common/djangoapps/student/templates/unsubscribed_emails/email/body.txt diff --git a/common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py b/common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py new file mode 100644 index 0000000000..a9abd8831d --- /dev/null +++ b/common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py @@ -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 diff --git a/common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py b/common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py new file mode 100644 index 0000000000..5a2a74302e --- /dev/null +++ b/common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py @@ -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() diff --git a/common/djangoapps/student/templates/unsubscribed_emails/email/base.html b/common/djangoapps/student/templates/unsubscribed_emails/email/base.html new file mode 100644 index 0000000000..57e31417b2 --- /dev/null +++ b/common/djangoapps/student/templates/unsubscribed_emails/email/base.html @@ -0,0 +1,14 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + + +{% block body %} +{% endblock body %} + + + diff --git a/common/djangoapps/student/templates/unsubscribed_emails/email/body.html b/common/djangoapps/student/templates/unsubscribed_emails/email/body.html new file mode 100644 index 0000000000..76c0f40937 --- /dev/null +++ b/common/djangoapps/student/templates/unsubscribed_emails/email/body.html @@ -0,0 +1,8 @@ +{% extends "unsubscribed_emails/email/base.html" %} +{% block body %} + +

+ Please find attached CSV file for Unsubscribed Emails from {{ start_date }} to {{ end_date }} +

+ +{% endblock body %} diff --git a/common/djangoapps/student/templates/unsubscribed_emails/email/body.txt b/common/djangoapps/student/templates/unsubscribed_emails/email/body.txt new file mode 100644 index 0000000000..03471646d4 --- /dev/null +++ b/common/djangoapps/student/templates/unsubscribed_emails/email/body.txt @@ -0,0 +1 @@ +Please find attached CSV file for Unsubscribed Emails from {{ start_date }} to {{ end_date }}