Add editable templates for bulk email
Adds the edX Marketing-approved template as html default.
@@ -3,7 +3,8 @@ Django admin page for bulk email models
|
||||
"""
|
||||
from django.contrib import admin
|
||||
|
||||
from bulk_email.models import CourseEmail, Optout
|
||||
from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate
|
||||
from bulk_email.forms import CourseEmailTemplateForm
|
||||
|
||||
|
||||
class CourseEmailAdmin(admin.ModelAdmin):
|
||||
@@ -16,5 +17,45 @@ class OptoutAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'course_id')
|
||||
|
||||
|
||||
class CourseEmailTemplateAdmin(admin.ModelAdmin):
|
||||
form = CourseEmailTemplateForm
|
||||
fieldsets = (
|
||||
(None, {
|
||||
# make the HTML template display above the plain template:
|
||||
'fields': ('html_template', 'plain_template'),
|
||||
'description': '''
|
||||
Enter template to be used by course staff when sending emails to enrolled students.
|
||||
|
||||
The HTML template is for HTML email, and may contain HTML markup. The plain template is
|
||||
for plaintext email. Both templates should contain the string '{{message_body}}' (with
|
||||
two curly braces on each side), to indicate where the email text is to be inserted.
|
||||
|
||||
Other tags that may be used (surrounded by one curly brace on each side):
|
||||
{platform_name} : the name of the platform
|
||||
{course_title} : the name of the course
|
||||
{course_url} : the course's full URL
|
||||
{email} : the user's email address
|
||||
{account_settings_url} : URL at which users can change email preferences
|
||||
{course_image_url} : URL for the course's course image.
|
||||
Will return a broken link if course doesn't have a course image set.
|
||||
|
||||
Note that there is currently NO validation on tags, so be careful. Typos or use of
|
||||
unsupported tags will cause email sending to fail.
|
||||
'''
|
||||
}),
|
||||
)
|
||||
# Turn off the action bar (we have no bulk actions)
|
||||
actions = None
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disables the ability to add new templates, as we want to maintain a Singleton."""
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Disables the ability to remove existing templates, as we want to maintain a Singleton."""
|
||||
return False
|
||||
|
||||
|
||||
admin.site.register(CourseEmail, CourseEmailAdmin)
|
||||
admin.site.register(Optout, OptoutAdmin)
|
||||
admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin)
|
||||
|
||||
268
lms/djangoapps/bulk_email/fixtures/plain-html.txt
Normal file
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html PUBLIC '-//W3C//DTD XHTML 1.0 Transitional//EN' 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd'>
|
||||
<html xmlns:fb='http://www.facebook.com/2008/fbml' xmlns:og='http://opengraph.org/schema/'> <head>
|
||||
<meta property='og:title' content='Update from {course_title}'/>
|
||||
<meta property='fb:page_id' content='43929265776' />
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8'>
|
||||
<title>Update from {course_title}</title>
|
||||
</head>
|
||||
<body leftmargin='0' marginwidth='0' topmargin='0' marginheight='0' offset='0' style='margin: 0;padding: 0;background-color: #ffffff;'>
|
||||
<center>
|
||||
<table align='center' border='0' cellpadding='0' cellspacing='0' height='100%' width='100%' id='bodyTable' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;margin: 0;padding: 0;background-color: #ffffff;height: 100% !important;width: 100% !important;'>
|
||||
<tr>
|
||||
<td align='center' valign='top' id='bodyCell' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;margin: 0;padding: 0;border-top: 0;height: 100% !important;width: 100% !important;'>
|
||||
<!-- BEGIN TEMPLATE // -->
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='100%' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<!-- BEGIN PREHEADER // -->
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='100%' id='templatePreheader' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #fcfcfc;border-top: 0;border-bottom: 0;'>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='600' class='templateContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tr>
|
||||
<td valign='top' class='preheaderContainer' style='padding-top: 9px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnTextBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnTextBlockOuter'>
|
||||
<tr>
|
||||
<td valign='top' class='mcnTextBlockInner' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
<table align='left' border='0' cellpadding='0' cellspacing='0' width='366' class='mcnTextContentContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
|
||||
<td valign='top' class='mcnTextContent' style='padding-top: 9px;padding-left: 18px;padding-bottom: 9px;padding-right: 0;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #606060;font-family: Helvetica;font-size: 11px;line-height: 125%;text-align: left;'>
|
||||
|
||||
<br>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END PREHEADER -->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<!-- BEGIN HEADER // -->
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='100%' id='templateHeader' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #fcfcfc;border-top: 0;border-bottom: 0;'>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='600' class='templateContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tr>
|
||||
<td valign='top' class='headerContainer' style='padding-top: 10px;padding-right: 18px;padding-bottom: 10px;padding-left: 18px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnImageBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnImageBlockOuter'>
|
||||
<tr>
|
||||
<td valign='top' style='padding: 9px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;' class='mcnImageBlockInner'>
|
||||
<table align='left' width='100%' border='0' cellpadding='0' cellspacing='0' class='mcnImageContentContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
<td class='mcnImageContent' valign='top' style='padding-right: 9px;padding-left: 9px;padding-top: 0;padding-bottom: 0;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
<a href='http://edx.org' title='' class='' target='_self' style='word-wrap: break-word !important;'>
|
||||
<img align='left' alt='edX' src='http://courses.edx.org/static/images/bulk_email/edXHeaderImage.jpg' width='564.0000152587891' style='max-width: 600px;padding-bottom: 0;display: inline !important;vertical-align: bottom;border: 0;line-height: 100%;outline: none;text-decoration: none;height: auto !important;' class='mcnImage'>
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnTextBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnTextBlockOuter'>
|
||||
<tr>
|
||||
<td valign='top' class='mcnTextBlockInner' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
<table align='left' border='0' cellpadding='0' cellspacing='0' width='599' class='mcnTextContentContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
|
||||
<td valign='top' class='mcnTextContent' style='padding-top: 9px;padding-right: 18px;padding-bottom: 9px;padding-left: 18px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #606060;font-family: Helvetica;font-size: 15px;line-height: 150%;text-align: left;'>
|
||||
|
||||
<div style='text-align: right;'>
|
||||
<span style='font-size:11px;'><span style='color:#00a0e3;'>Connect with edX:</span></span> <a href='http://facebook.com/edxonline' target='_blank' style='color: #6DC6DD;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/FacebookIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a> <a href='http://twitter.com/edxonline' target='_blank' style='color: #6DC6DD;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/TwitterIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a> <a href='https://plus.google.com/108235383044095082735' target='_blank' style='color: #6DC6DD;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/GooglePlusIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a> <a href='http://www.meetup.com/edX-Communities/' target='_blank' style='color: #6DC6DD;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/MeetupIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END HEADER -->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<!-- BEGIN BODY // -->
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='100%' id='templateBody' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #fcfcfc;border-top: 0;border-bottom: 0;'>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='600' class='templateContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tr>
|
||||
<td valign='top' class='bodyContainer' style='padding-top: 10px;padding-right: 18px;padding-bottom: 10px;padding-left: 18px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnCaptionBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnCaptionBlockOuter'>
|
||||
<tr>
|
||||
<td class='mcnCaptionBlockInner' valign='top' style='padding: 9px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
|
||||
<table border='0' cellpadding='0' cellspacing='0' class='mcnCaptionLeftContentOuter' width='100%' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
<td valign='top' class='mcnCaptionLeftContentInner' style='padding: 0 9px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<table align='right' border='0' cellpadding='0' cellspacing='0' class='mcnCaptionLeftImageContentContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
<td class='mcnCaptionLeftImageContent' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
|
||||
<img alt='' src='{course_image_url}' width='176' style='max-width: 180px;border: 0;line-height: 100%;outline: none;text-decoration: none;vertical-align: bottom;height: auto !important;' class='mcnImage'>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<table class='mcnCaptionLeftTextContentContainer' align='left' border='0' cellpadding='0' cellspacing='0' width='352' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
<td valign='top' class='mcnTextContent' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #606060;font-family: Helvetica;font-size: 14px;line-height: 150%;text-align: left;'>
|
||||
<h3 class='null' style='display: block;font-family: Helvetica;font-size: 18px;font-style: normal;font-weight: bold;line-height: 125%;letter-spacing: -.5px;margin: 0;text-align: left;color: #606060 !important;'>
|
||||
<strong style='font-size: 22px;'>{course_title}</strong><br></h3>
|
||||
|
||||
<br>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnTextBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnTextBlockOuter'>
|
||||
<tr>
|
||||
<td valign='top' class='mcnTextBlockInner' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
<table align='left' border='0' cellpadding='0' cellspacing='0' width='600' class='mcnTextContentContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
|
||||
<td valign='top' class='mcnTextContent' style='padding-top: 9px;padding-right: 18px;padding-bottom: 9px;padding-left: 18px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #606060;font-family: Helvetica;font-size: 14px;line-height: 150%;text-align: left;'>
|
||||
{{message_body}}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnDividerBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnDividerBlockOuter'>
|
||||
<tr>
|
||||
<td class='mcnDividerBlockInner' style='padding: 18px 18px 3px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<table class='mcnDividerContent' border='0' cellpadding='0' cellspacing='0' width='100%' style='border-top-width: 1px;border-top-style: solid;border-top-color: #666666;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
<td style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<span></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnTextBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnTextBlockOuter'>
|
||||
<tr>
|
||||
<td valign='top' class='mcnTextBlockInner' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
<table align='left' border='0' cellpadding='0' cellspacing='0' width='600' class='mcnTextContentContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
|
||||
<td valign='top' class='mcnTextContent' style='padding-top: 9px;padding-right: 18px;padding-bottom: 9px;padding-left: 18px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #606060;font-family: Helvetica;font-size: 14px;line-height: 150%;text-align: left;'>
|
||||
|
||||
<div style='text-align: right;'>
|
||||
<a href='http://facebook.com/edxonline' target='_blank' style='color: #2f73bc;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/FacebookIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a> <a href='http://twitter.com/edxonline' target='_blank' style='color: #2f73bc;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/TwitterIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a> <a href='https://plus.google.com/108235383044095082735' target='_blank' style='color: #2f73bc;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/GooglePlusIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a> <a href='http://www.meetup.com/edX-Communities/' target='_blank' style='color: #2f73bc;font-weight: normal;text-decoration: underline;word-wrap: break-word !important;'><img align='none' height='16' src='http://courses.edx.org/static/images/bulk_email/MeetupIcon.png' style='width: 16px;height: 16px;border: 0;line-height: 100%;outline: none;text-decoration: none;' width='16'></a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END BODY -->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<!-- BEGIN FOOTER // -->
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='100%' id='templateFooter' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #006ba4;border-top: 0;border-bottom: 0;'>
|
||||
<tr>
|
||||
<td align='center' valign='top' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<table border='0' cellpadding='0' cellspacing='0' width='600' class='templateContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tr>
|
||||
<td valign='top' class='footerContainer' style='padding-top: 10px;padding-right: 18px;padding-bottom: 10px;padding-left: 18px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'><table border='0' cellpadding='0' cellspacing='0' width='100%' class='mcnTextBlock' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody class='mcnTextBlockOuter'>
|
||||
<tr>
|
||||
<td valign='top' class='mcnTextBlockInner' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
|
||||
<table align='left' border='0' cellpadding='0' cellspacing='0' width='600' class='mcnTextContentContainer' style='border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;'>
|
||||
<tbody><tr>
|
||||
|
||||
<td valign='top' class='mcnTextContent' style='padding-top: 9px;padding-right: 18px;padding-bottom: 9px;padding-left: 18px;border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #f2f2f2;font-family: Helvetica;font-size: 11px;line-height: 125%;text-align: left;'>
|
||||
|
||||
<em>Copyright © 2013 edX, All rights reserved.</em><br>
|
||||
<br>
|
||||
<br>
|
||||
<b>Our mailing address is:</b><br>
|
||||
edX<br>
|
||||
11 Cambridge Center, Suite 101<br>
|
||||
Cambridge, MA, USA 02142<br>
|
||||
<br>
|
||||
<br>
|
||||
This email was automatically sent from {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='{account_settings_url}'>here</a>. <br>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END FOOTER -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END TEMPLATE -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body> </body> </html>
|
||||
42
lms/djangoapps/bulk_email/forms.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from bulk_email.models import CourseEmailTemplate, COURSE_EMAIL_MESSAGE_BODY_TAG
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseEmailTemplateForm(forms.ModelForm):
|
||||
"""Form providing validation of CourseEmail templates."""
|
||||
|
||||
class Meta:
|
||||
model = CourseEmailTemplate
|
||||
|
||||
def _validate_template(self, template):
|
||||
"""Check the template for required tags."""
|
||||
index = template.find(COURSE_EMAIL_MESSAGE_BODY_TAG)
|
||||
if index < 0:
|
||||
msg = 'Missing tag: "{}"'.format(COURSE_EMAIL_MESSAGE_BODY_TAG)
|
||||
log.warning(msg)
|
||||
raise ValidationError(msg)
|
||||
if template.find(COURSE_EMAIL_MESSAGE_BODY_TAG, index + 1) >= 0:
|
||||
msg = 'Multiple instances of tag: "{}"'.format(COURSE_EMAIL_MESSAGE_BODY_TAG)
|
||||
log.warning(msg)
|
||||
raise ValidationError(msg)
|
||||
# TODO: add more validation here, including the set of known tags
|
||||
# for which values will be supplied. (Email will fail if the template
|
||||
# uses tags for which values are not supplied.)
|
||||
|
||||
def clean_html_template(self):
|
||||
"""Validate the HTML template."""
|
||||
template = self.cleaned_data["html_template"]
|
||||
self._validate_template(template)
|
||||
return template
|
||||
|
||||
def clean_plain_template(self):
|
||||
"""Validate the plaintext template."""
|
||||
template = self.cleaned_data["plain_template"]
|
||||
self._validate_template(template)
|
||||
return template
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'CourseEmailTemplate'
|
||||
db.create_table('bulk_email_courseemailtemplate', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('html_template', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
|
||||
('plain_template', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
|
||||
))
|
||||
db.send_create_signal('bulk_email', ['CourseEmailTemplate'])
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'CourseEmailTemplate'
|
||||
db.delete_table('bulk_email_courseemailtemplate')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'bulk_email.courseemail': {
|
||||
'Meta': {'object_name': 'CourseEmail'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
|
||||
'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
|
||||
'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'})
|
||||
},
|
||||
'bulk_email.courseemailtemplate': {
|
||||
'Meta': {'object_name': 'CourseEmailTemplate'},
|
||||
'html_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'plain_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'bulk_email.optout': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['bulk_email']
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.v2 import DataMigration
|
||||
|
||||
|
||||
class Migration(DataMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
"Load data from fixture."
|
||||
from django.core.management import call_command
|
||||
call_command("loaddata", "course_email_template.json")
|
||||
|
||||
def backwards(self, orm):
|
||||
"Perform a no-op to go backwards."
|
||||
pass
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'bulk_email.courseemail': {
|
||||
'Meta': {'object_name': 'CourseEmail'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
|
||||
'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
|
||||
'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'})
|
||||
},
|
||||
'bulk_email.courseemailtemplate': {
|
||||
'Meta': {'object_name': 'CourseEmailTemplate'},
|
||||
'html_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'plain_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'bulk_email.optout': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['bulk_email']
|
||||
symmetrical = True
|
||||
@@ -10,13 +10,13 @@ file and check it in at the same time as your model changes. To do that,
|
||||
2. ./manage.py lms schemamigration bulk_email --auto description_of_your_change
|
||||
3. Add the migration file created in edx-platform/lms/djangoapps/bulk_email/migrations/
|
||||
|
||||
|
||||
ASSUMPTIONS: modules have unique IDs, even across different module_types
|
||||
|
||||
"""
|
||||
import logging
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Email(models.Model):
|
||||
"""
|
||||
@@ -33,6 +33,10 @@ class Email(models.Model):
|
||||
class Meta: # pylint: disable=C0111
|
||||
abstract = True
|
||||
|
||||
SEND_TO_MYSELF = 'myself'
|
||||
SEND_TO_STAFF = 'staff'
|
||||
SEND_TO_ALL = 'all'
|
||||
|
||||
|
||||
class CourseEmail(Email, models.Model):
|
||||
"""
|
||||
@@ -48,12 +52,12 @@ class CourseEmail(Email, models.Model):
|
||||
# (student, staff, or instructor)
|
||||
#
|
||||
TO_OPTIONS = (
|
||||
('myself', 'Myself'),
|
||||
('staff', 'Staff and instructors'),
|
||||
('all', 'All')
|
||||
(SEND_TO_MYSELF, 'Myself'),
|
||||
(SEND_TO_STAFF, 'Staff and instructors'),
|
||||
(SEND_TO_ALL, 'All')
|
||||
)
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
to_option = models.CharField(max_length=64, choices=TO_OPTIONS, default='myself')
|
||||
to_option = models.CharField(max_length=64, choices=TO_OPTIONS, default=SEND_TO_MYSELF)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.subject
|
||||
@@ -63,8 +67,89 @@ class Optout(models.Model):
|
||||
"""
|
||||
Stores users that have opted out of receiving emails from a course.
|
||||
"""
|
||||
# Allowing null=True to support data migration from email->user.
|
||||
# We need to first create the 'user' column with some sort of default in order to run the data migration,
|
||||
# and given the unique index, 'null' is the best default value.
|
||||
user = models.ForeignKey(User, db_index=True, null=True)
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
|
||||
class Meta: # pylint: disable=C0111
|
||||
unique_together = ('user', 'course_id')
|
||||
|
||||
|
||||
# Defines the tag that must appear in a template, to indicate
|
||||
# the location where the email message body is to be inserted.
|
||||
COURSE_EMAIL_MESSAGE_BODY_TAG = '{{message_body}}'
|
||||
|
||||
|
||||
class CourseEmailTemplate(models.Model):
|
||||
"""
|
||||
Stores templates for all emails to a course to use.
|
||||
|
||||
This is expected to be a singleton, to be shared across all courses.
|
||||
Initialization takes place in a migration that in turn loads a fixture.
|
||||
The admin console interface disables add and delete operations.
|
||||
Validation is handled in the CourseEmailTemplateForm class.
|
||||
"""
|
||||
html_template = models.TextField(null=True, blank=True)
|
||||
plain_template = models.TextField(null=True, blank=True)
|
||||
|
||||
@staticmethod
|
||||
def get_template():
|
||||
"""
|
||||
Fetch the current template
|
||||
|
||||
If one isn't stored, an exception is thrown.
|
||||
"""
|
||||
return CourseEmailTemplate.objects.get()
|
||||
|
||||
@staticmethod
|
||||
def _render(format_string, message_body, context):
|
||||
"""
|
||||
Create a text message using a template, message body and context.
|
||||
|
||||
Convert message body (`message_body`) into an email message
|
||||
using the provided template. The template is a format string,
|
||||
which is rendered using format() with the provided `context` dict.
|
||||
|
||||
This doesn't insert user's text into template, until such time we can
|
||||
support proper error handling due to errors in the message body
|
||||
(e.g. due to the use of curly braces).
|
||||
|
||||
Instead, for now, we insert the message body *after* the substitutions
|
||||
have been performed, so that anything in the message body that might
|
||||
interfere will be innocently returned as-is.
|
||||
|
||||
Output is returned as a unicode string. It is not encoded as utf-8.
|
||||
Such encoding is left to the email code, which will use the value
|
||||
of settings.DEFAULT_CHARSET to encode the message.
|
||||
"""
|
||||
# If we wanted to support substitution, we'd call:
|
||||
# format_string = format_string.replace(COURSE_EMAIL_MESSAGE_BODY_TAG, message_body)
|
||||
result = format_string.format(**context)
|
||||
# Note that the body tag in the template will now have been
|
||||
# "formatted", so we need to do the same to the tag being
|
||||
# searched for.
|
||||
message_body_tag = COURSE_EMAIL_MESSAGE_BODY_TAG.format()
|
||||
result = result.replace(message_body_tag, message_body, 1)
|
||||
|
||||
# finally, return the result, without converting to an encoded byte array.
|
||||
return result
|
||||
|
||||
def render_plaintext(self, plaintext, context):
|
||||
"""
|
||||
Create plain text message.
|
||||
|
||||
Convert plain text body (`plaintext`) into plaintext email message using the
|
||||
stored plain template and the provided `context` dict.
|
||||
"""
|
||||
return CourseEmailTemplate._render(self.plain_template, plaintext, context)
|
||||
|
||||
def render_htmltext(self, htmltext, context):
|
||||
"""
|
||||
Create HTML text message.
|
||||
|
||||
Convert HTML text body (`htmltext`) into HTML email message using the
|
||||
stored HTML template and the provided `context` dict.
|
||||
"""
|
||||
return CourseEmailTemplate._render(self.html_template, htmltext, context)
|
||||
|
||||
@@ -5,7 +5,6 @@ to a course.
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import gc
|
||||
|
||||
from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError
|
||||
|
||||
@@ -15,11 +14,14 @@ from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.http import Http404
|
||||
from celery import task, current_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from bulk_email.models import CourseEmail, Optout
|
||||
from bulk_email.models import (
|
||||
CourseEmail, Optout, CourseEmailTemplate,
|
||||
SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_ALL,
|
||||
)
|
||||
from courseware.access import _course_staff_group_name, _course_instructor_group_name
|
||||
from courseware.courses import get_course_by_id
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from courseware.courses import get_course_by_id, course_image_url
|
||||
|
||||
log = get_task_logger(__name__)
|
||||
|
||||
@@ -44,12 +46,30 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id):
|
||||
try:
|
||||
CourseEmail.objects.get(id=email_id)
|
||||
except CourseEmail.DoesNotExist as exc:
|
||||
# The retry behavior here is necessary because of a race condition between the commit of the transaction
|
||||
# that creates this CourseEmail row and the celery pipeline that starts this task.
|
||||
# We might possibly want to move the blocking into the view function rather than have it in this task.
|
||||
log.warning("Failed to get CourseEmail with id %s, retry %d", email_id, current_task.request.retries)
|
||||
raise delegate_email_batches.retry(arg=[email_id, to_option, course_id, course_url, user_id], exc=exc)
|
||||
raise delegate_email_batches.retry(arg=[email_id, user_id], exc=exc)
|
||||
|
||||
if to_option == "myself":
|
||||
to_option = email_obj.to_option
|
||||
course_id = email_obj.course_id
|
||||
|
||||
try:
|
||||
course = get_course_by_id(course_id, depth=1)
|
||||
except Http404 as exc:
|
||||
log.exception("get_course_by_id failed: %s", exc.args[0])
|
||||
raise Exception("get_course_by_id failed: " + exc.args[0])
|
||||
|
||||
course_url = 'https://{}{}'.format(
|
||||
settings.SITE_NAME,
|
||||
reverse('course_root', kwargs={'course_id': course_id})
|
||||
)
|
||||
image_url = 'https://{}{}'.format(settings.SITE_NAME, course_image_url(course))
|
||||
|
||||
if to_option == SEND_TO_MYSELF:
|
||||
recipient_qset = User.objects.filter(id=user_id)
|
||||
elif to_option == "all" or to_option == "staff":
|
||||
elif to_option == SEND_TO_ALL or to_option == SEND_TO_STAFF:
|
||||
staff_grpname = _course_staff_group_name(course.location)
|
||||
staff_group, _ = Group.objects.get_or_create(name=staff_grpname)
|
||||
staff_qset = staff_group.user_set.all()
|
||||
@@ -58,7 +78,7 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id):
|
||||
instructor_qset = instructor_group.user_set.all()
|
||||
recipient_qset = staff_qset | instructor_qset
|
||||
|
||||
if to_option == "all":
|
||||
if to_option == SEND_TO_ALL:
|
||||
enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id,
|
||||
courseenrollment__is_active=True)
|
||||
recipient_qset = recipient_qset | enrollment_qset
|
||||
@@ -67,12 +87,13 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id):
|
||||
log.error("Unexpected bulk email TO_OPTION found: %s", to_option)
|
||||
raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option))
|
||||
|
||||
image_url = course_image_url(course)
|
||||
recipient_qset = recipient_qset.order_by('pk')
|
||||
total_num_emails = recipient_qset.count()
|
||||
num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY)))
|
||||
last_pk = recipient_qset[0].pk - 1
|
||||
num_workers = 0
|
||||
for j in range(num_queries):
|
||||
for _ in range(num_queries):
|
||||
recipient_sublist = list(recipient_qset.order_by('pk').filter(pk__gt=last_pk)
|
||||
.values('profile__name', 'email', 'pk')[:settings.EMAILS_PER_QUERY])
|
||||
last_pk = recipient_sublist[-1]['pk']
|
||||
@@ -81,76 +102,86 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id):
|
||||
chunk = int(math.ceil(float(num_emails_this_query) / float(num_tasks_this_query)))
|
||||
for i in range(num_tasks_this_query):
|
||||
to_list = recipient_sublist[i * chunk:i * chunk + chunk]
|
||||
course_email.delay(email_id, to_list, course.display_name, course_url, False)
|
||||
course_email.delay(
|
||||
email_id,
|
||||
to_list,
|
||||
course.display_name,
|
||||
course_url,
|
||||
image_url,
|
||||
False
|
||||
)
|
||||
num_workers += num_tasks_this_query
|
||||
gc.collect()
|
||||
return num_workers
|
||||
|
||||
|
||||
@task(default_retry_delay=15, max_retries=5) # pylint: disable=E1102
|
||||
def course_email(email_id, to_list, course_title, course_url, throttle=False):
|
||||
def course_email(email_id, to_list, course_title, course_url, image_url, throttle=False):
|
||||
"""
|
||||
Takes a subject and an html formatted email and sends it from
|
||||
sender to all addresses in the to_list, with each recipient
|
||||
being the only "to". Emails are sent multipart, in both plain
|
||||
Takes a primary id for a CourseEmail object and a 'to_list' of recipient objects--keys are
|
||||
'profile__name', 'email' (address), and 'pk' (in the user table).
|
||||
course_title, course_url, and image_url are to memoize course properties and save lookups.
|
||||
|
||||
Sends to all addresses contained in to_list. Emails are sent multi-part, in both plain
|
||||
text and html.
|
||||
"""
|
||||
|
||||
try:
|
||||
msg = CourseEmail.objects.get(id=email_id)
|
||||
except CourseEmail.DoesNotExist as exc:
|
||||
log.exception(exc.args[0])
|
||||
raise exc
|
||||
except CourseEmail.DoesNotExist:
|
||||
log.exception("Could not find email id:{} to send.".format(email_id))
|
||||
raise
|
||||
|
||||
# exclude optouts
|
||||
optouts = Optout.objects.filter(course_id=msg.course_id,
|
||||
user__email__in=[i['email'] for i in to_list])\
|
||||
.values_list('user__email', flat=True)
|
||||
optouts = (Optout.objects.filter(course_id=msg.course_id,
|
||||
user__in=[i['pk'] for i in to_list])
|
||||
.values_list('user__email', flat=True))
|
||||
|
||||
num_optout = len(optouts)
|
||||
|
||||
to_list = filter(lambda x: x['email'] not in optouts, to_list)
|
||||
to_list = filter(lambda x: x['email'] not in set(optouts), to_list)
|
||||
|
||||
subject = "[" + course_title + "] " + msg.subject
|
||||
|
||||
course_title_no_quotes = re.sub(r'"', '', course_title)
|
||||
from_addr = '"{0}" Course Staff <{1}>'.format(course_title_no_quotes, settings.DEFAULT_BULK_FROM_EMAIL)
|
||||
|
||||
course_email_template = CourseEmailTemplate.get_template()
|
||||
|
||||
try:
|
||||
connection = get_connection()
|
||||
connection.open()
|
||||
num_sent = 0
|
||||
num_error = 0
|
||||
|
||||
# Define context values to use in all course emails:
|
||||
email_context = {
|
||||
'name': '',
|
||||
'email': '',
|
||||
'course_title': course_title,
|
||||
'course_url': course_url
|
||||
'course_url': course_url,
|
||||
'course_image_url': image_url,
|
||||
'account_settings_url': 'https://{}{}'.format(settings.SITE_NAME, reverse('dashboard')),
|
||||
'platform_name': settings.PLATFORM_NAME,
|
||||
}
|
||||
|
||||
while to_list:
|
||||
# Update context with user-specific values:
|
||||
email = to_list[-1]['email']
|
||||
email_context['email'] = email
|
||||
email_context['name'] = to_list[-1]['profile__name']
|
||||
|
||||
html_footer = render_to_string(
|
||||
'emails/email_footer.html',
|
||||
email_context
|
||||
)
|
||||
# Construct message content using templates and context:
|
||||
plaintext_msg = course_email_template.render_plaintext(msg.text_message, email_context)
|
||||
html_msg = course_email_template.render_htmltext(msg.html_message, email_context)
|
||||
|
||||
plain_footer = render_to_string(
|
||||
'emails/email_footer.txt',
|
||||
email_context
|
||||
)
|
||||
# Create email:
|
||||
email_msg = EmailMultiAlternatives(
|
||||
subject,
|
||||
msg.text_message + plain_footer.encode('utf-8'),
|
||||
plaintext_msg,
|
||||
from_addr,
|
||||
[email],
|
||||
connection=connection
|
||||
)
|
||||
email_msg.attach_alternative(msg.html_message + html_footer.encode('utf-8'), 'text/html')
|
||||
email_msg.attach_alternative(html_msg, 'text/html')
|
||||
|
||||
# Throttle if we tried a few times and got the rate limiter
|
||||
if throttle or current_task.request.retries > 0:
|
||||
@@ -183,6 +214,7 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False):
|
||||
to_list,
|
||||
course_title,
|
||||
course_url,
|
||||
image_url,
|
||||
current_task.request.retries > 0
|
||||
],
|
||||
exc=exc,
|
||||
|
||||
@@ -4,6 +4,7 @@ Unit tests for student optouts from course email
|
||||
import json
|
||||
|
||||
from django.core import mail
|
||||
from django.core.management import call_command
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
@@ -30,6 +31,9 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
|
||||
self.student = UserFactory.create()
|
||||
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
|
||||
|
||||
# load initial content (since we don't run migrations as part of tests):
|
||||
call_command("loaddata", "course_email_template.json")
|
||||
|
||||
self.client.login(username=self.student.username, password="test")
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
"""
|
||||
Unit tests for sending course email
|
||||
"""
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
from django.core import mail
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.management import call_command
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory
|
||||
@@ -63,6 +64,9 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
|
||||
for student in self.students:
|
||||
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
|
||||
|
||||
# load initial content (since we don't run migrations as part of tests):
|
||||
call_command("loaddata", "course_email_template.json")
|
||||
|
||||
self.client.login(username=self.instructor.username, password="test")
|
||||
|
||||
# Pull up email view on instructor dashboard
|
||||
@@ -208,10 +212,8 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
|
||||
[self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students]
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
uni_message,
|
||||
mail.outbox[0].body
|
||||
)
|
||||
message_body = mail.outbox[0].body
|
||||
self.assertIn(uni_message, message_body)
|
||||
|
||||
def test_unicode_students_send_to_all(self):
|
||||
"""
|
||||
@@ -273,11 +275,12 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
self.assertEquals(mock_factory.emails_sent,
|
||||
1 + len(self.staff) + len(self.students) + LARGE_NUM_EMAILS - len(optouts))
|
||||
self.assertItemsEqual(
|
||||
[e.to[0] for e in mail.outbox],
|
||||
[self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] +
|
||||
[s.email for s in added_users if s not in optouts]
|
||||
)
|
||||
outbox_contents = [e.to[0] for e in mail.outbox]
|
||||
should_send_contents = ([self.instructor.email] +
|
||||
[s.email for s in self.staff] +
|
||||
[s.email for s in self.students] +
|
||||
[s.email for s in added_users if s not in optouts])
|
||||
self.assertItemsEqual(outbox_contents, should_send_contents)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
@@ -294,4 +297,4 @@ class TestEmailSendExceptions(ModuleStoreTestCase):
|
||||
def test_no_course_email_obj(self):
|
||||
# Make sure course_email handles CourseEmail.DoesNotExist exception.
|
||||
with self.assertRaises(CourseEmail.DoesNotExist):
|
||||
course_email(101, [], "_", "_", False)
|
||||
course_email(101, [], "_", "_", "_", False)
|
||||
|
||||
@@ -4,6 +4,7 @@ Unit tests for handling email sending errors
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
@@ -35,6 +36,9 @@ class TestEmailErrors(ModuleStoreTestCase):
|
||||
instructor = AdminFactory.create()
|
||||
self.client.login(username=instructor.username, password="test")
|
||||
|
||||
# load initial content (since we don't run migrations as part of tests):
|
||||
call_command("loaddata", "course_email_template.json")
|
||||
|
||||
self.smtp_server_thread = FakeSMTPServerThread('localhost', TEST_SMTP_PORT)
|
||||
self.smtp_server_thread.start()
|
||||
|
||||
|
||||
@@ -718,7 +718,13 @@ def instructor_dashboard(request, course_id):
|
||||
email.save()
|
||||
|
||||
course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id}))
|
||||
tasks.delegate_email_batches.delay(email.id, email.to_option, course_id, course_url, request.user.id)
|
||||
tasks.delegate_email_batches.delay(
|
||||
email.id,
|
||||
email.to_option,
|
||||
course_id,
|
||||
course_url,
|
||||
request.user.id
|
||||
)
|
||||
|
||||
if to_option == "all":
|
||||
email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.</p></div>'
|
||||
|
||||
BIN
lms/static/images/bulk_email/FacebookIcon.png
Normal file
|
After Width: | Height: | Size: 550 B |
BIN
lms/static/images/bulk_email/GooglePlusIcon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
lms/static/images/bulk_email/LinkedInIcon.png
Normal file
|
After Width: | Height: | Size: 751 B |
BIN
lms/static/images/bulk_email/MeetupIcon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
lms/static/images/bulk_email/TwitterIcon.png
Normal file
|
After Width: | Height: | Size: 998 B |
BIN
lms/static/images/bulk_email/VKontakteIcon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
lms/static/images/bulk_email/edXHeaderImage.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |