From 3b32d4216c430d908d57f3b905ab40f1fb50a2cb Mon Sep 17 00:00:00 2001 From: Kevin Luo <kevkluo@gmail.com> Date: Fri, 26 Jul 2013 16:21:50 -0700 Subject: [PATCH] Add delay to course bulk email task and use SITE_NAME for site url Delay for possible race condition with fetching course email object. Use settings.SITE_NAME for host name to generate email footer url. --- lms/djangoapps/bulk_email/admin.py | 13 ++++++- lms/djangoapps/bulk_email/models.py | 3 ++ lms/djangoapps/bulk_email/tasks.py | 37 ++++++++++++------- lms/djangoapps/bulk_email/tests/fake_smtp.py | 18 ++++++--- .../bulk_email/tests/smtp_server_thread.py | 18 ++++++--- .../bulk_email/tests/test_course_optout.py | 9 ++++- lms/djangoapps/bulk_email/tests/test_email.py | 13 +++++-- .../bulk_email/tests/test_err_handling.py | 13 +++++-- lms/djangoapps/instructor/views/legacy.py | 14 +++---- lms/static/sass/base/_variables.scss | 5 +++ .../courseware/instructor_dashboard.html | 6 +-- lms/templates/emails/email_footer.html | 4 +- lms/templates/emails/email_footer.txt | 4 +- 13 files changed, 108 insertions(+), 49 deletions(-) diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index fe151741bc4..40da36629c8 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -1,11 +1,20 @@ +""" +Django admin page for bulk email models +""" from django.contrib import admin from bulk_email.models import CourseEmail, Optout -admin.site.register(Optout) - class CourseEmailAdmin(admin.ModelAdmin): + """Admin for course email.""" readonly_fields = ('sender',) + +class OptoutAdmin(admin.ModelAdmin): + """Admin for optouts.""" + list_display = ('email', 'course_id') + + admin.site.register(CourseEmail, CourseEmailAdmin) +admin.site.register(Optout, OptoutAdmin) diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index 2804521841e..96753a44a71 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -1,3 +1,6 @@ +""" +Models for bulk email +""" from django.db import models from django.contrib.auth.models import User diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 9047f4e5695..3da03bb3dc6 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -1,3 +1,7 @@ +""" +This module contains celery task functions for handling the sending of bulk email +to a course. +""" import logging import math import re @@ -20,24 +24,30 @@ from mitxmako.shortcuts import render_to_string log = logging.getLogger(__name__) -@task() -def delegate_email_batches(hash_for_msg, recipient, course_id, course_url, user_id): - ''' +@task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102 +def delegate_email_batches(hash_for_msg, to_option, course_id, course_url, user_id): + """ Delegates emails by querying for the list of recipients who should get the mail, chopping up into batches of settings.EMAILS_PER_TASK size, and queueing up worker jobs. - Recipient is {'students', 'staff', or 'all'} + `to_option` is {'students', 'staff', or 'all'} Returns the number of batches (workers) kicked off. - ''' + """ try: course = get_course_by_id(course_id) except Http404 as exc: log.error("get_course_by_id failed: " + exc.args[0]) raise Exception("get_course_by_id failed: " + exc.args[0]) - if recipient == "myself": + try: + CourseEmail.objects.get(hash=hash_for_msg) + except CourseEmail.DoesNotExist as exc: + log.warning("Failed to get CourseEmail with hash %s, retry %d", hash_for_msg, current_task.request.retries) + raise delegate_email_batches.retry(arg=[hash_for_msg, to_option, course_id, course_url, user_id], exc=exc) + + if to_option == "myself": recipient_qset = User.objects.filter(id=user_id).values('profile__name', 'email') else: staff_grpname = _course_staff_group_name(course.location) @@ -48,9 +58,9 @@ def delegate_email_batches(hash_for_msg, recipient, course_id, course_url, user_ instructor_qset = instructor_group.user_set.values('profile__name', 'email') recipient_qset = staff_qset | instructor_qset - if recipient == "all": - #Execute two queries per performance considerations for MySQL - #https://docs.djangoproject.com/en/1.2/ref/models/querysets/#in + if to_option == "all": + # Two queries are executed per performance considerations for MySQL. + # See https://docs.djangoproject.com/en/1.2/ref/models/querysets/#in. course_optouts = Optout.objects.filter(course_id=course_id).values_list('email', flat=True) enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id).exclude(email__in=list(course_optouts)).values('profile__name', 'email') recipient_qset = recipient_qset | enrollment_qset @@ -67,7 +77,7 @@ def delegate_email_batches(hash_for_msg, recipient, course_id, course_url, user_ return num_workers -@task(default_retry_delay=15, max_retries=5) +@task(default_retry_delay=15, max_retries=5) # pylint: disable=E1102 def course_email(hash_for_msg, to_list, course_title, course_url, throttle=False): """ Takes a subject and an html formatted email and sends it from @@ -127,7 +137,7 @@ def course_email(hash_for_msg, to_list, course_title, course_url, throttle=False raise exc # this will cause the outer handler to catch the exception and retry the entire task else: #this will fall through and not retry the message, since it will be popped - log.warn('Email with hash ' + hash_for_msg + ' not delivered to ' + email + ' due to error: ' + exc.smtp_error) + log.warning('Email with hash ' + hash_for_msg + ' not delivered to ' + email + ' due to error: ' + exc.smtp_error) num_error += 1 to_list.pop() @@ -140,6 +150,7 @@ def course_email(hash_for_msg, to_list, course_title, course_url, throttle=False raise course_email.retry(arg=[hash_for_msg, to_list, course_title, course_url, current_task.request.retries > 0], exc=exc, countdown=(2 ** current_task.request.retries) * 15) -#This string format code is wrapped in this function to allow mocking for a unit test +# This string format code is wrapped in this function to allow mocking for a unit test def course_email_result(num_sent, num_error): - return "Sent %d, Fail %d" % (num_sent, num_error) + """Return the formatted result of course_email sending.""" + return "Sent {0}, Fail {1}".format(num_sent, num_error) diff --git a/lms/djangoapps/bulk_email/tests/fake_smtp.py b/lms/djangoapps/bulk_email/tests/fake_smtp.py index cf5872c3330..b6fb9b92a5a 100755 --- a/lms/djangoapps/bulk_email/tests/fake_smtp.py +++ b/lms/djangoapps/bulk_email/tests/fake_smtp.py @@ -51,11 +51,18 @@ class FakeSMTPServer(smtpd.SMTPServer): def __init__(self, *args, **kwargs): smtpd.SMTPServer.__init__(self, *args, **kwargs) self.errtype = None - self.reply = None + self.response = None - def set_errtype(self, errtype, reply=''): + def set_errtype(self, errtype, response=''): + """Specify the type of error to cause smptlib to raise, with optional response string. + + `errtype` -- "DATA": The server will cause smptlib to throw SMTPDataError. + "CONN": The server will cause smptlib to throw SMTPConnectError. + "DISCONN": The server will cause smptlib to throw SMTPServerDisconnected. + + """ self.errtype = errtype - self.reply = reply + self.response = response def handle_accept(self): if self.errtype == "DISCONN": @@ -70,11 +77,12 @@ class FakeSMTPServer(smtpd.SMTPServer): def process_message(self, *_args, **_kwargs): if self.errtype == "DATA": - #after failing on the first email, succeed on rest + # After failing on the first email, succeed on the rest. self.errtype = None - return self.reply + return self.response else: return None def serve_forever(self): + """Start the server running until close() is called on the server.""" asyncore.loop() diff --git a/lms/djangoapps/bulk_email/tests/smtp_server_thread.py b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py index 77b51509f13..713cd9ca642 100644 --- a/lms/djangoapps/bulk_email/tests/smtp_server_thread.py +++ b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py @@ -1,10 +1,14 @@ +""" +Defines a class for a thread that runs a Fake SMTP server, used for testing +error handling from sending email. +""" import threading from bulk_email.tests.fake_smtp import FakeSMTPServer class FakeSMTPServerThread(threading.Thread): """ - Thread for running a fake SMTP server for testing email + Thread for running a fake SMTP server """ def __init__(self, host, port): self.host = host @@ -19,21 +23,25 @@ class FakeSMTPServerThread(threading.Thread): super(FakeSMTPServerThread, self).start() self.is_ready.wait() if self.error: - raise self.error + raise self.error # pylint: disable=E0702 def stop(self): + """ + Stop the thread by closing the server instance. + Wait for the server thread to terminate. + """ if hasattr(self, 'server'): self.server.close() self.join() def run(self): """ - Sets up the test smtp server and handle requests + Sets up the test smtp server and handle requests. """ try: self.server = FakeSMTPServer((self.host, self.port), None) self.is_ready.set() self.server.serve_forever() - except Exception, e: - self.error = e + except Exception, exc: # pylint: disable=W0703 + self.error = exc self.is_ready.set() diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index e91b11c3140..ece0e879c74 100644 --- a/lms/djangoapps/bulk_email/tests/test_course_optout.py +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -14,6 +14,11 @@ from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentF @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestOptoutCourseEmails(ModuleStoreTestCase): + + """ + Test that optouts are referenced in sending course email. + """ + def setUp(self): self.course = CourseFactory.create() self.instructor = AdminFactory.create() @@ -34,7 +39,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.client.login(username=self.instructor.username, password="test") url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + response = self.client.post(url, {'action': 'Send email', 'to_option': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) self.assertContains(response, "Your email was successfully queued for sending.") #assert that self.student.email not in mail.to, outbox should be empty @@ -52,7 +57,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.client.login(username=self.instructor.username, password="test") url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + response = self.client.post(url, {'action': 'Send email', 'to_option': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) self.assertContains(response, "Your email was successfully queued for sending.") #assert that self.student.email in mail.to diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index 7aebd679f38..bc626d2d3bd 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -18,6 +18,11 @@ STUDENT_COUNT = 10 @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestEmail(ModuleStoreTestCase): + + """ + Test that emails send correctly. + """ + def setUp(self): self.course = CourseFactory.create() self.instructor = UserFactory.create(username="instructor", email="robot+instructor@edx.org") @@ -29,7 +34,7 @@ class TestEmail(ModuleStoreTestCase): self.staff = [UserFactory() for _ in xrange(STAFF_COUNT)] staff_group = GroupFactory() for staff in self.staff: - staff_group.user_set.add(staff) + staff_group.user_set.add(staff) # pylint: disable=E1101 #create students self.students = [UserFactory() for _ in xrange(STUDENT_COUNT)] @@ -43,7 +48,7 @@ class TestEmail(ModuleStoreTestCase): Make sure email send to myself goes to myself. """ url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - response = self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + response = self.client.post(url, {'action': 'Send email', 'to_option': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) self.assertContains(response, "Your email was successfully queued for sending.") @@ -57,7 +62,7 @@ class TestEmail(ModuleStoreTestCase): Make sure email send to staff and instructors goes there. """ url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - response = self.client.post(url, {'action': 'Send email', 'to': 'staff', 'subject': 'test subject for staff', 'message': 'test message for subject'}) + response = self.client.post(url, {'action': 'Send email', 'to_option': 'staff', 'subject': 'test subject for staff', 'message': 'test message for subject'}) self.assertContains(response, "Your email was successfully queued for sending.") @@ -69,7 +74,7 @@ class TestEmail(ModuleStoreTestCase): Make sure email send to all goes there. """ url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + response = self.client.post(url, {'action': 'Send email', 'to_option': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) self.assertContains(response, "Your email was successfully queued for sending.") diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index faf6d38a87d..fd900cffc33 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -19,6 +19,11 @@ TEST_SMTP_PORT = 1025 @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', EMAIL_HOST='localhost', EMAIL_PORT=TEST_SMTP_PORT) class TestEmailErrors(ModuleStoreTestCase): + + """ + Test that errors from sending email are handled properly. + """ + def setUp(self): self.course = CourseFactory.create() instructor = AdminFactory.create() @@ -37,7 +42,7 @@ class TestEmailErrors(ModuleStoreTestCase): """ self.smtp_server_thread.server.set_errtype("DATA", "454 Throttling failure: Daily message quota exceeded.") url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + self.client.post(url, {'action': 'Send email', 'to_option': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) self.assertTrue(retry.called) (_, kwargs) = retry.call_args exc = kwargs['exc'] @@ -55,7 +60,7 @@ class TestEmailErrors(ModuleStoreTestCase): CourseEnrollmentFactory.create(user=student, course_id=self.course.id) url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + self.client.post(url, {'action': 'Send email', 'to_option': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) self.assertFalse(retry.called) #test that after the failed email, the rest send successfully @@ -70,7 +75,7 @@ class TestEmailErrors(ModuleStoreTestCase): """ self.smtp_server_thread.server.set_errtype("DISCONN", "Server disconnected, please try again later.") url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + self.client.post(url, {'action': 'Send email', 'to_option': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) self.assertTrue(retry.called) (_, kwargs) = retry.call_args exc = kwargs['exc'] @@ -84,7 +89,7 @@ class TestEmailErrors(ModuleStoreTestCase): #SMTP reply is already specified in fake SMTP Channel created self.smtp_server_thread.server.set_errtype("CONN") url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + self.client.post(url, {'action': 'Send email', 'to_option': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) self.assertTrue(retry.called) (_, kwargs) = retry.call_args exc = kwargs['exc'] diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 0aacf516a87..a6cc2cf40bc 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -83,7 +83,7 @@ def instructor_dashboard(request, course_id): forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) msg = '' - to = None + to_option = None subject = None html_message = '' problems = [] @@ -701,13 +701,13 @@ def instructor_dashboard(request, course_id): # email elif action == 'Send email': - to = request.POST.get("to") + to_option = request.POST.get("to_option") subject = request.POST.get("subject") html_message = request.POST.get("message") email = CourseEmail(course_id=course_id, sender=request.user, - to=to, + to=to_option, subject=subject, html_message=html_message, hash=md5((html_message + subject + datetime.datetime.isoformat(datetime.datetime.now())).encode('utf-8')).hexdigest()) @@ -716,7 +716,7 @@ def instructor_dashboard(request, course_id): course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id})) tasks.delegate_email_batches.delay(email.hash, email.to, course_id, course_url, request.user.id) - if to == "all": + if to_option == "all": msg = "<font color='green'>Your email was successfully queued for sending. Please note that for large public classe\ s (~10k), it may take 1-2 hours to send all emails.</font>" else: @@ -810,9 +810,9 @@ s (~10k), it may take 1-2 hours to send all emails.</font>" 'course_stats': course_stats, 'msg': msg, 'modeflag': {idash_mode: 'selectedmode'}, - 'to': to, # email - 'subject': subject, # email - 'editor': editor, # email + 'to_option': to_option, # email + 'subject': subject, # email + 'editor': editor, # email 'problems': problems, # psychometrics 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 29fab36f1c3..ef9ac2a5b30 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -44,6 +44,11 @@ $dark-gray: #333; // used by descriptor css $lightGrey: #edf1f5; $darkGrey: #8891a1; +$blue-d1: shade($blue,20%); +$blue-d2: shade($blue,40%); +$blue-d4: shade($blue,80%); +$shadow: rgba($black, 0.2); +$shadow-l1: rgba($black, 0.1); // edx.org marketing site variables $m-gray: #8A8C8F; diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 35e15076fe5..e2435122292 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -445,14 +445,14 @@ function goto( mode) %if modeflag.get('Email'): <p> <label for="id_to">Send to:</label> - <select id="id_to" name="to"> + <select id="id_to" name="to_option"> <option value="myself">Myself</option> - %if to == "staff": + %if to_option == "staff": <option value="staff" selected="selected">Staff and instructors</option> %else: <option value="staff">Staff and instructors</option> %endif - %if to == "all": + %if to_option == "all": <option value="all" selected="selected">All (students, staff and instructors)</option> %else: <option value="all">All (students, staff and instructors)</option> diff --git a/lms/templates/emails/email_footer.html b/lms/templates/emails/email_footer.html index 577512a0153..ad9813c601f 100644 --- a/lms/templates/emails/email_footer.html +++ b/lms/templates/emails/email_footer.html @@ -1,6 +1,6 @@ <%! from django.core.urlresolvers import reverse %> <br /> ----<br /> -This email was automatically sent from ${settings.PLATFORM_NAME} to ${name}. <br /> +This email was automatically sent from ${settings.PLATFORM_NAME}. <br /> You are receiving this email at address ${ email } because you are enrolled in <a href="${course_url}">${ course_title }</a>.<br /> -To stop receiving email like this, update your course email settings <a href="https://${site}${reverse('dashboard')}">here</a>. <br /> +To stop receiving email like this, update your course email settings <a href="https://${settings.SITE_NAME}${reverse('dashboard')}">here</a>. <br /> diff --git a/lms/templates/emails/email_footer.txt b/lms/templates/emails/email_footer.txt index 3d530010ce0..95dffc218e9 100644 --- a/lms/templates/emails/email_footer.txt +++ b/lms/templates/emails/email_footer.txt @@ -1,7 +1,7 @@ <%! from django.core.urlresolvers import reverse %> ---- -This email was automatically sent from ${settings.PLATFORM_NAME} to ${name}. +This email was automatically sent from ${settings.PLATFORM_NAME}. You are receiving this email at address ${ email } because you are enrolled in ${ course_title } (URL: ${course_url} ). -To stop receiving email like this, update your account settings at https://${site}${reverse('dashboard')}. +To stop receiving email like this, update your account settings at https://${settings.SITE_NAME}${reverse('dashboard')}. -- GitLab