From 5791fd10871020d35ae29dfcf1bb1f01b1ce9327 Mon Sep 17 00:00:00 2001 From: njdup Date: Tue, 8 Jul 2014 15:01:22 -0700 Subject: [PATCH] Instructors can view previously sent email content Previously on the send email page of the instructor dashboard, instructors could only view task information about emails they've sent for their course in the past. In addition to this, I've now added the ability to see the content of all previously sent emails. A "Sent Email History" button has been added to the page. When clicked, a table displaying the subject line, number of emails sent, and date/time of submission for each previously sent email is created. An instructor can then click on any subject line to see the content of that email, displayed in a modal window that appears on the page. The window is also equipped with a "copy email to editor" button, which copies the emails contents to the tinyMCE editor, so that an instructor can easily resend an email that they've sent in the past. --- CHANGELOG.rst | 2 + lms/djangoapps/instructor/tests/test_api.py | 110 ++++++++++++++++- lms/djangoapps/instructor/tests/utils.py | 84 +++++++++++++ lms/djangoapps/instructor/views/api.py | 65 ++++------ lms/djangoapps/instructor/views/api_urls.py | 2 + .../instructor/views/instructor_dashboard.py | 4 +- .../views/instructor_task_helpers.py | 113 +++++++++++++++++ .../instructor_dashboard/send_email.coffee | 25 +++- .../src/instructor_dashboard/util.coffee | 115 ++++++++++++++++++ lms/static/js/toggle_login_modal.js | 38 ++++-- lms/static/sass/course/instructor/_email.scss | 60 ++++++++- .../instructor_dashboard_2/send_email.html | 23 +++- 12 files changed, 576 insertions(+), 65 deletions(-) create mode 100644 lms/djangoapps/instructor/tests/utils.py create mode 100644 lms/djangoapps/instructor/views/instructor_task_helpers.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6c71fb77fd..d794f638ea 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Instructors can request and see content of previous bulk emails sent in the instructor dashboard. + Studio: New advanced setting "invitation_only" for courses. This setting overrides the enrollment start/end dates if set. LMS-2670 diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index e506a52fd9..2a19eab346 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -8,6 +8,7 @@ import json import requests import datetime import ddt +import random from urllib import quote from django.test import TestCase from nose.tools import raises @@ -31,6 +32,8 @@ from student.tests.factories import UserFactory from courseware.tests.factories import StaffFactory, InstructorFactory, BetaTesterFactory from student.roles import CourseBetaTesterRole from microsite_configuration import microsite +from util.date_utils import get_default_time_display +from instructor.tests.utils import FakeContentTask, FakeEmail, FakeEmailInfo from student.models import CourseEnrollment, CourseEnrollmentAllowed from courseware.models import StudentModule @@ -1321,7 +1324,6 @@ class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase self.assertNotIn(rolename, user_roles) - @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCase): """ @@ -1802,7 +1804,7 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): act.return_value = self.tasks url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id.to_deprecated_string()}) mock_factory = MockCompletionInfo() - with patch('instructor.views.api.get_task_completion_info') as mock_completion_info: + with patch('instructor.views.instructor_task_helpers.get_task_completion_info') as mock_completion_info: mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info response = self.client.get(url, {}) self.assertEqual(response.status_code, 200) @@ -1821,7 +1823,7 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): act.return_value = self.tasks url = reverse('list_background_email_tasks', kwargs={'course_id': self.course.id.to_deprecated_string()}) mock_factory = MockCompletionInfo() - with patch('instructor.views.api.get_task_completion_info') as mock_completion_info: + with patch('instructor.views.instructor_task_helpers.get_task_completion_info') as mock_completion_info: mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info response = self.client.get(url, {}) self.assertEqual(response.status_code, 200) @@ -1840,7 +1842,7 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): act.return_value = self.tasks url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id.to_deprecated_string()}) mock_factory = MockCompletionInfo() - with patch('instructor.views.api.get_task_completion_info') as mock_completion_info: + with patch('instructor.views.instructor_task_helpers.get_task_completion_info') as mock_completion_info: mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info response = self.client.get(url, { 'problem_location_str': self.problem_urlname, @@ -1861,7 +1863,7 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): act.return_value = self.tasks url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id.to_deprecated_string()}) mock_factory = MockCompletionInfo() - with patch('instructor.views.api.get_task_completion_info') as mock_completion_info: + with patch('instructor.views.instructor_task_helpers.get_task_completion_info') as mock_completion_info: mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info response = self.client.get(url, { 'problem_location_str': self.problem_urlname, @@ -1879,6 +1881,104 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertEqual(actual_tasks, expected_tasks) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +@patch.object(instructor_task.api, 'get_instructor_task_history') +class TestInstructorEmailContentList(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test the instructor email content history endpoint. + """ + def setUp(self): + self.course = CourseFactory.create() + self.instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password='test') + + def tearDown(self): + """ + Undo all patches. + """ + patch.stopall() + + def setup_fake_email_info(self, num_emails): + """ Initialize the specified number of fake emails """ + self.tasks = {} + self.emails = {} + self.emails_info = {} + for email_id in range(num_emails): + num_sent = random.randint(1, 15401) + self.tasks[email_id] = FakeContentTask(email_id, num_sent, 'expected') + self.emails[email_id] = FakeEmail(email_id) + self.emails_info[email_id] = FakeEmailInfo(self.emails[email_id], num_sent) + + def get_matching_mock_email(self, *args, **kwargs): + """ Returns the matching mock emails for the given id """ + email_id = kwargs.get('id', 0) + return self.emails[email_id] + + def get_email_content_response(self, num_emails, task_history_request): + """ Calls the list_email_content endpoint and returns the repsonse """ + self.setup_fake_email_info(num_emails) + task_history_request.return_value = self.tasks.values() + url = reverse('list_email_content', kwargs={'course_id': self.course.id.to_deprecated_string()}) + with patch('instructor.views.api.CourseEmail.objects.get') as mock_email_info: + mock_email_info.side_effect = self.get_matching_mock_email + response = self.client.get(url, {}) + self.assertEqual(response.status_code, 200) + return response + + def test_content_list_one_email(self, task_history_request): + """ Test listing of bulk emails when email list has one email """ + response = self.get_email_content_response(1, task_history_request) + self.assertTrue(task_history_request.called) + email_info = json.loads(response.content)['emails'] + + # Emails list should have one email + self.assertEqual(len(email_info), 1) + + # Email content should be what's expected + expected_message = self.emails[0].html_message + returned_email_info = email_info[0] + received_message = returned_email_info[u'email'][u'html_message'] + self.assertEqual(expected_message, received_message) + + def test_content_list_no_emails(self, task_history_request): + """ Test listing of bulk emails when email list empty """ + response = self.get_email_content_response(0, task_history_request) + self.assertTrue(task_history_request.called) + email_info = json.loads(response.content)['emails'] + + # Emails list should be empty + self.assertEqual(len(email_info), 0) + + def test_content_list_email_content_many(self, task_history_request): + """ Test listing of bulk emails sent large amount of emails """ + response = self.get_email_content_response(50, task_history_request) + self.assertTrue(task_history_request.called) + expected_email_info = [email_info.to_dict() for email_info in self.emails_info.values()] + actual_email_info = json.loads(response.content)['emails'] + + self.assertEqual(len(actual_email_info), 50) + for exp_email, act_email in zip(expected_email_info, actual_email_info): + self.assertDictEqual(exp_email, act_email) + + self.assertEqual(actual_email_info, expected_email_info) + + def test_list_email_content_error(self, task_history_request): + """ Test handling of error retrieving email """ + self.invalid_task = FakeContentTask(0, 0, 'test') + self.invalid_task.make_invalid_input() + task_history_request.return_value = [self.invalid_task] + url = reverse('list_email_content', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url, {}) + self.assertEqual(response.status_code, 200) + + self.assertTrue(task_history_request.called) + returned_email_info = json.loads(response.content)['emails'] + self.assertEqual(len(returned_email_info), 1) + returned_info = returned_email_info[0] + for info in ['created', 'sent_to', 'email', 'number_sent']: + self.assertEqual(returned_info[info], None) + + @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(ANALYTICS_SERVER_URL="http://robotanalyticsserver.netbot:900/") @override_settings(ANALYTICS_API_KEY="robot_api_key") diff --git a/lms/djangoapps/instructor/tests/utils.py b/lms/djangoapps/instructor/tests/utils.py new file mode 100644 index 0000000000..8607fd84a6 --- /dev/null +++ b/lms/djangoapps/instructor/tests/utils.py @@ -0,0 +1,84 @@ +""" +Utilities for instructor unit tests +""" +import datetime +import json +import random +from django.utils.timezone import utc +from util.date_utils import get_default_time_display + + +class FakeInfo(object): + """Parent class for faking objects used in tests""" + FEATURES = [] + + def __init__(self): + for feature in self.FEATURES: + setattr(self, feature, u'expected') + + def to_dict(self): + """ Returns a dict representation of the object """ + return {key: getattr(self, key) for key in self.FEATURES} + + +class FakeContentTask(FakeInfo): + """ Fake task info needed for email content list """ + FEATURES = [ + 'task_input', + 'task_output', + ] + + def __init__(self, email_id, num_sent, sent_to): + super(FakeContentTask, self).__init__() + self.task_input = {'email_id': email_id, 'to_option': sent_to} + self.task_input = json.dumps(self.task_input) + self.task_output = {'total': num_sent} + self.task_output = json.dumps(self.task_output) + + def make_invalid_input(self): + """Corrupt the task input field to test errors""" + self.task_input = "THIS IS INVALID JSON" + + +class FakeEmail(FakeInfo): + """ Corresponding fake email for a fake task """ + FEATURES = [ + 'subject', + 'html_message', + 'id', + 'created', + ] + + def __init__(self, email_id): + super(FakeEmail, self).__init__() + self.id = unicode(email_id) + # Select a random data for create field + year = random.choice(range(1950, 2000)) + month = random.choice(range(1, 12)) + day = random.choice(range(1, 28)) + hour = random.choice(range(0, 23)) + minute = random.choice(range(0, 59)) + self.created = datetime.datetime(year, month, day, hour, minute, tzinfo=utc) + + +class FakeEmailInfo(FakeInfo): + """ Fake email information object """ + FEATURES = [ + u'created', + u'sent_to', + u'email', + u'number_sent' + ] + + EMAIL_FEATURES = [ + u'subject', + u'html_message', + u'id' + ] + + def __init__(self, fake_email, num_sent): + super(FakeEmailInfo, self).__init__() + self.created = get_default_time_display(fake_email.created) + self.number_sent = num_sent + fake_email_dict = fake_email.to_dict() + self.email = {feature: fake_email_dict[feature] for feature in self.EMAIL_FEATURES} diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 67e2eaa84c..d6f26531cb 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -20,6 +20,8 @@ from django.utils.translation import ugettext as _ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.utils.html import strip_tags from util.json_request import JsonResponse +from util.date_utils import get_default_time_display +from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features from courseware.access import has_access from courseware.courses import get_course_with_access, get_course_by_id @@ -36,7 +38,6 @@ from courseware.models import StudentModule from student.models import CourseEnrollment, unique_id_for_user, anonymous_id_for_user import instructor_task.api from instructor_task.api_helper import AlreadyRunningError -from instructor_task.views import get_task_completion_info from instructor_task.models import ReportStore import instructor.enrollment as enrollment from instructor.enrollment import ( @@ -52,7 +53,7 @@ import instructor_analytics.distributions import instructor_analytics.csvs import csv -from submissions import api as sub_api # installed from the edx-submissions repository +from submissions import api as sub_api # installed from the edx-submissions repository from bulk_email.models import CourseEmail @@ -610,9 +611,10 @@ def get_anon_ids(request, course_id): # pylint: disable=W0613 Respond with 2-column CSV output of user-id, anonymized-user-id """ # TODO: the User.objects query and CSV generation here could be - # centralized into instructor_analytics. Currently instructor_analytics + # centralized into instructor_analytics. Currently instructor_analytics # has similar functionality but not quite what's needed. course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + def csv_response(filename, header, rows): """Returns a CSV http response for the given header and rows (excel/utf-8).""" response = HttpResponse(mimetype='text/csv') @@ -850,45 +852,6 @@ def rescore_problem(request, course_id): return JsonResponse(response_payload) -def extract_task_features(task): - """ - Convert task to dict for json rendering. - Expects tasks have the following features: - * task_type (str, type of task) - * task_input (dict, input(s) to the task) - * task_id (str, celery id of the task) - * requester (str, username who submitted the task) - * task_state (str, state of task eg PROGRESS, COMPLETED) - * created (datetime, when the task was completed) - * task_output (optional) - """ - # Pull out information from the task - features = ['task_type', 'task_input', 'task_id', 'requester', 'task_state'] - task_feature_dict = {feature: str(getattr(task, feature)) for feature in features} - # Some information (created, duration, status, task message) require additional formatting - task_feature_dict['created'] = task.created.isoformat() - - # Get duration info, if known - duration_sec = 'unknown' - if hasattr(task, 'task_output') and task.task_output is not None: - try: - task_output = json.loads(task.task_output) - except ValueError: - log.error("Could not parse task output as valid json; task output: %s", task.task_output) - else: - if 'duration_ms' in task_output: - duration_sec = int(task_output['duration_ms'] / 1000.0) - task_feature_dict['duration_sec'] = duration_sec - - # Get progress status message & success information - success, task_message = get_task_completion_info(task) - status = _("Complete") if success else _("Incomplete") - task_feature_dict['status'] = status - task_feature_dict['task_message'] = task_message - - return task_feature_dict - - @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -907,6 +870,24 @@ def list_background_email_tasks(request, course_id): # pylint: disable=unused-a return JsonResponse(response_payload) +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def list_email_content(requests, course_id): + """ + List the content of bulk emails sent + """ + course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + task_type = 'bulk_course_email' + # First get tasks list of bulk emails sent + emails = instructor_task.api.get_instructor_task_history(course_id, task_type=task_type) + + response_payload = { + 'emails': map(extract_email_features, emails), + } + return JsonResponse(response_payload) + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index ef632e4b91..e20f2988a7 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -31,6 +31,8 @@ urlpatterns = patterns('', # nopep8 'instructor.views.api.list_instructor_tasks', name="list_instructor_tasks"), url(r'^list_background_email_tasks$', 'instructor.views.api.list_background_email_tasks', name="list_background_email_tasks"), + url(r'^list_email_content$', + 'instructor.views.api.list_email_content', name="list_email_content"), url(r'^list_forum_members$', 'instructor.views.api.list_forum_members', name="list_forum_members"), url(r'^update_forum_role_membership$', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 43a4eeb12c..d9118d3016 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -92,7 +92,6 @@ def instructor_dashboard_2(request, course_id): if course_mode_has_price: sections.append(_section_e_commerce(course_key, access)) - studio_url = None if is_studio_course: studio_url = get_cms_course_link(course) @@ -278,6 +277,9 @@ def _section_send_email(course_key, access, course): 'email_background_tasks_url': reverse( 'list_background_email_tasks', kwargs={'course_id': course_key.to_deprecated_string()} ), + 'email_content_history_url': reverse( + 'list_email_content', kwargs={'course_id': course_key.to_deprecated_string()} + ), } return section_data diff --git a/lms/djangoapps/instructor/views/instructor_task_helpers.py b/lms/djangoapps/instructor/views/instructor_task_helpers.py new file mode 100644 index 0000000000..4c89e6addd --- /dev/null +++ b/lms/djangoapps/instructor/views/instructor_task_helpers.py @@ -0,0 +1,113 @@ +""" +A collection of helper utility functions for working with instructor +tasks. +""" +import json +import logging +from util.date_utils import get_default_time_display +from bulk_email.models import CourseEmail +from django.utils.translation import ugettext as _ +from instructor_task.views import get_task_completion_info + +log = logging.getLogger(__name__) + + +def email_error_information(): + """ + Returns email information marked as None, used in event email + cannot be loaded + """ + expected_info = [ + 'created', + 'sent_to', + 'email', + 'number_sent' + ] + return {info: None for info in expected_info} + + +def extract_email_features(email_task): + """ + From the given task, extract email content information + + Expects that the given task has the following attributes: + * task_input (dict containing email_id and to_option) + * task_output (optional, dict containing total emails sent) + + With this information, gets the corresponding email object from the + bulk emails table, and loads up a dict containing the following: + * created, the time the email was sent displayed in default time display + * sent_to, the group the email was delivered to + * email, dict containing the subject, id, and html_message of an email + * number_sent, int number of emails sent + If task_input cannot be loaded, then the email cannot be loaded + and None is returned for these fields. + """ + # Load the task input info to get email id + try: + task_input_information = json.loads(email_task.task_input) + except ValueError: + log.error("Could not parse task input as valid json; task input: %s", email_task.task_input) + return email_error_information() + + email = CourseEmail.objects.get(id=task_input_information['email_id']) + + creation_time = get_default_time_display(email.created) + email_feature_dict = {'created': creation_time, 'sent_to': task_input_information['to_option']} + features = ['subject', 'html_message', 'id'] + email_info = {feature: unicode(getattr(email, feature)) for feature in features} + + # Pass along email as an object with the information we desire + email_feature_dict['email'] = email_info + + number_sent = None + if hasattr(email_task, 'task_output') and email_task.task_output is not None: + try: + task_output = json.loads(email_task.task_output) + except ValueError: + log.error("Could not parse task output as valid json; task output: %s", email_task.task_output) + else: + if 'total' in task_output: + number_sent = int(task_output['total']) + email_feature_dict['number_sent'] = number_sent + + return email_feature_dict + + +def extract_task_features(task): + """ + Convert task to dict for json rendering. + Expects tasks have the following features: + * task_type (str, type of task) + * task_input (dict, input(s) to the task) + * task_id (str, celery id of the task) + * requester (str, username who submitted the task) + * task_state (str, state of task eg PROGRESS, COMPLETED) + * created (datetime, when the task was completed) + * task_output (optional) + """ + # Pull out information from the task + features = ['task_type', 'task_input', 'task_id', 'requester', 'task_state'] + task_feature_dict = {feature: str(getattr(task, feature)) for feature in features} + # Some information (created, duration, status, task message) require additional formatting + task_feature_dict['created'] = task.created.isoformat() + + # Get duration info, if known + duration_sec = 'unknown' + if hasattr(task, 'task_output') and task.task_output is not None: + try: + task_output = json.loads(task.task_output) + except ValueError: + log.error("Could not parse task output as valid json; task output: %s", task.task_output) + else: + if 'duration_ms' in task_output: + duration_sec = int(task_output['duration_ms'] / 1000.0) + task_feature_dict['duration_sec'] = duration_sec + + # Get progress status message & success information + success, task_message = get_task_completion_info(task) + status = _("Complete") if success else _("Incomplete") + task_feature_dict['status'] = status + task_feature_dict['task_message'] = task_message + + return task_feature_dict diff --git a/lms/static/coffee/src/instructor_dashboard/send_email.coffee b/lms/static/coffee/src/instructor_dashboard/send_email.coffee index 772dddad21..d26ed4a287 100644 --- a/lms/static/coffee/src/instructor_dashboard/send_email.coffee +++ b/lms/static/coffee/src/instructor_dashboard/send_email.coffee @@ -11,6 +11,8 @@ plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, argum std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks create_task_list_table = -> window.InstructorDashboard.util.create_task_list_table.apply this, arguments +create_email_content_table = -> window.InstructorDashboard.util.create_email_content_table.apply this, arguments +create_email_message_views = -> window.InstructorDashboard.util.create_email_message_views.apply this, arguments class SendEmail constructor: (@$container) -> @@ -21,9 +23,14 @@ class SendEmail @$btn_send = @$container.find("input[name='send']'") @$task_response = @$container.find(".request-response") @$request_response_error = @$container.find(".request-response-error") + @$content_request_response_error = @$container.find(".content-request-response-error") @$history_request_response_error = @$container.find(".history-request-response-error") @$btn_task_history_email = @$container.find("input[name='task-history-email']'") + @$btn_task_history_email_content = @$container.find("input[name='task-history-email-content']'") @$table_task_history_email = @$container.find(".task-history-email-table") + @$table_email_content_history = @$container.find(".content-history-email-table") + @$email_content_table_inner = @$container.find(".content-history-table-inner") + @$email_messages_wrapper = @$container.find(".email-messages-wrapper") # attach click handlers @@ -83,10 +90,26 @@ class SendEmail else @$history_request_response_error.text gettext("There is no email history for this course.") # Enable the msg-warning css display - $(".msg-warning").css({"display":"block"}) + @$history_request_response_error.css({"display":"block"}) error: std_ajax_err => @$history_request_response_error.text gettext("There was an error obtaining email task history for this course.") + # List content history for emails sent + @$btn_task_history_email_content.click => + url = @$btn_task_history_email_content.data 'endpoint' + $.ajax + dataType: 'json' + url : url + success: (data) => + if data.emails.length + create_email_content_table @$table_email_content_history, @$email_content_table_inner, data.emails + create_email_message_views @$email_messages_wrapper, data.emails + else + @$content_request_response_error.text gettext("There is no email history for this course.") + @$content_request_response_error.css({"display":"block"}) + error: std_ajax_err => + @$content_request_response_error.text gettext("There was an error obtaining email content history for this course.") + fail_with_error: (msg) -> console.warn msg @$task_response.empty() diff --git a/lms/static/coffee/src/instructor_dashboard/util.coffee b/lms/static/coffee/src/instructor_dashboard/util.coffee index 839472ef73..1010474651 100644 --- a/lms/static/coffee/src/instructor_dashboard/util.coffee +++ b/lms/static/coffee/src/instructor_dashboard/util.coffee @@ -119,6 +119,119 @@ create_task_list_table = ($table_tasks, tasks_data) -> $table_tasks.append $table_placeholder grid = new Slick.Grid($table_placeholder, table_data, columns, options) +# Formats the subject field for email content history table +subject_formatter = (row, cell, value, columnDef, dataContext) -> + if !value then return gettext("An error occurred retrieving your email. Please try again later, and contact technical support if the problem persists.") + subject_text = $('').text(value['subject']).html() + return '

' + subject_text + '

' + +# Formats the created field for the email content history table +created_formatter = (row, cell, value, columnDef, dataContext) -> + if !value then return "

" + gettext("Unknown") + "

" else return '

' + value + '

' + +# Formats the number sent field for the email content history table +number_sent_formatter = (row, cell, value, columndDef, dataContext) -> + if !value then return "

" + gettext("Unknown") + "

" else return '

' + value + '

' + +# Creates a table to display the content of bulk course emails +# sent in the past +create_email_content_table = ($table_emails, $table_emails_inner, email_data) -> + $table_emails_inner.empty() + $table_emails.show() + + options = + enableCellNavigation: true + enableColumnReorder: false + autoHeight: true + rowHeight: 50 + forceFitColumns: true + + columns = [ + id: 'email' + field: 'email' + name: gettext('Subject') + minWidth: 80 + cssClass: "email-content-cell" + formatter: subject_formatter + , + id: 'created' + field: 'created' + name: gettext('Time Sent') + minWidth: 80 + cssClass: "email-content-cell" + formatter: created_formatter + , + id: 'number_sent' + field: 'number_sent' + name: gettext('Number Sent') + minwidth: 100 + maxWidth: 150 + cssClass: "email-content-cell" + formatter: number_sent_formatter + , + ] + + table_data = email_data + + $table_placeholder = $ '
', class: 'slickgrid' + $table_emails_inner.append $table_placeholder + grid = new Slick.Grid($table_placeholder, table_data, columns, options) + $table_emails.append $ '
' + +# Creates the modal windows linked to each email in the email history +# Displayed when instructor clicks an email's subject in the content history table +create_email_message_views = ($messages_wrapper, emails) -> + $messages_wrapper.empty() + for email_info in emails + + # If some error occured, bail out + if !email_info.email then return + + # Create hidden section for modal window + email_id = email_info.email['id'] + $message_content = $('
', "aria-hidden": "true", class: "modal email-modal", id: "email_message_" + email_id) + $email_wrapper = $ '
', class: 'inner-wrapper email-content-wrapper' + $email_header = $ '
', class: 'email-content-header' + + # Add copy email body button + $email_header.append $('', type: "button", name: "copy-email-body-text", value: gettext("Copy Email To Editor"), id: "copy_email_" + email_id) + + $close_button = $ '', href: '#', class: "close-modal" + $close_button.append $ '', class: 'icon-remove' + $email_header.append $close_button + + # HTML escape the subject line + subject_text = $('').text(email_info.email['subject']).html() + $email_header.append $('

', class: "message-bold").html('' + gettext('Subject:') + ' ' + subject_text) + + $email_header.append $('

', class: "message-bold").html('' + gettext('Time Sent:') + ' ' + email_info.created) + $email_header.append $('

', class: "message-bold").html('' + gettext('Sent To:') + ' ' + email_info.sent_to) + $email_wrapper.append $email_header + + $email_wrapper.append $ '
' + + # Last, add email content section + $email_content = $ '
', class: 'email-content-message' + $email_content.append $('

', class: "message-bold").html("" + gettext("Message:") + "") + $message = $('
').html(email_info.email['html_message']) + $email_content.append $message + $email_wrapper.append $email_content + + $message_content.append $email_wrapper + $messages_wrapper.append $message_content + + # Setup buttons to open modal window and copy an email message + $('#email_message_' + email_info.email['id'] + '_trig').leanModal({closeButton: ".close-modal", copyEmailButton: "#copy_email_" + email_id}) + setup_copy_email_button(email_id, email_info.email['html_message'], email_info.email['subject']) + +# Helper method to set click handler for modal copy email button +setup_copy_email_button = (email_id, html_message, subject) -> + $("#copy_email_" + email_id).click => + editor = tinyMCE.get("mce_0") + editor.setContent(html_message) + $('#id_subject').val(subject) + + # Helper class for managing the execution of interval tasks. # Handles pausing and restarting. class IntervalManager @@ -178,4 +291,6 @@ if _? std_ajax_err: std_ajax_err IntervalManager: IntervalManager create_task_list_table: create_task_list_table + create_email_content_table: create_email_content_table + create_email_message_views: create_email_message_views PendingInstructorTasks: PendingInstructorTasks diff --git a/lms/static/js/toggle_login_modal.js b/lms/static/js/toggle_login_modal.js index 28bec08988..e8310716e3 100644 --- a/lms/static/js/toggle_login_modal.js +++ b/lms/static/js/toggle_login_modal.js @@ -17,7 +17,7 @@ closeButton: null, position: 'fixed' } - + if ($("#lean_overlay").length == 0) { var overlay = $("
"); $("body").append(overlay); @@ -52,6 +52,11 @@ close_modal(modal_id, e); }); + // To enable closing of email modal when copy button hit + $(o.copyEmailButton).click(function(e) { + close_modal(modal_id, e); + }); + var modal_height = $(modal_id).outerHeight(); var modal_width = $(modal_id).outerWidth(); @@ -59,17 +64,28 @@ $('#lean_overlay').fadeTo(200,o.overlay); $('iframe', modal_id).attr('src', $('iframe', modal_id).data('src')); - $(modal_id).css({ - 'display' : 'block', - 'position' : o.position, - 'opacity' : 0, - 'z-index': 11000, - 'left' : 50 + '%', - 'margin-left' : -(modal_width/2) + "px", - 'top' : o.top + "px" - }) + if ($(modal_id).hasClass("email-modal")){ + $(modal_id).css({ + 'width' : 80 + '%', + 'height' : 80 + '%', + 'position' : o.position, + 'opacity' : 0, + 'z-index' : 11000, + 'left' : 10 + '%', + 'top' : 10 + '%' + }) + } else { + $(modal_id).css({ + 'position' : o.position, + 'opacity' : 0, + 'z-index': 11000, + 'left' : 50 + '%', + 'margin-left' : -(modal_width/2) + "px", + 'top' : o.top + "px" + }) + } - $(modal_id).fadeTo(200,1); + $(modal_id).show().fadeTo(200,1); $(modal_id).find(".notice").hide().html(""); var notice = $(this).data('notice') if(notice !== undefined) { diff --git a/lms/static/sass/course/instructor/_email.scss b/lms/static/sass/course/instructor/_email.scss index bc38d64394..859a9733f8 100644 --- a/lms/static/sass/course/instructor/_email.scss +++ b/lms/static/sass/course/instructor/_email.scss @@ -20,9 +20,67 @@ margin-top: 10px; line-height: 1.3; - ul { + ul { margin-top: 0; margin-bottom: 10px; } } +.email-background{ + .content-history-email-table { + display: none; + } + + .email-content-wrapper { + min-height: 100%; + background: #f5f5f5; + + hr { + width: 90%; + margin-left: 5%; + margin-top: 0; + } + } + + .message-bold em { + font-weight: bold; + font-style: normal; + } + + .email-content-header { + padding: 20px 5%; + + h2 { + text-align: left; + padding-top: 10px; + margin: 0; + } + + input { + margin-top: 15px; + float: right; + } + } + + .email-content-message { + padding: 5px 5% 40px 5%; + } + + .email-modal { + overflow: auto; + color: $black; + } + + .email-content-cell { + p { + padding: 15px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + a:hover { + font-weight: bold; + } + } +} diff --git a/lms/templates/instructor/instructor_dashboard_2/send_email.html b/lms/templates/instructor/instructor_dashboard_2/send_email.html index 2e157af047..b44c87bc6c 100644 --- a/lms/templates/instructor/instructor_dashboard_2/send_email.html +++ b/lms/templates/instructor/instructor_dashboard_2/send_email.html @@ -69,12 +69,27 @@
%endif