Merge pull request #555 from edx/feature/kluo/bulk-email-squashed
Course email for instructors
2
AUTHORS
@@ -84,3 +84,5 @@ Mukul Goyal <miki@edx.org>
|
||||
Robert Marks <rmarks@edx.org>
|
||||
Yarko Tymciurak <yarkot1@gmail.com>
|
||||
Miles Steele <miles@milessteele.com>
|
||||
Kevin Luo <kevluo@edx.org>
|
||||
Akshay Jagadeesh <akjags@gmail.com>
|
||||
|
||||
@@ -33,6 +33,9 @@ logic has been consolidated into the model -- you should use new class methods
|
||||
to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating
|
||||
CourseEnrollment objects or querying them directly.
|
||||
|
||||
LMS: Added bulk email for course feature, with option to optout of individual
|
||||
course emails.
|
||||
|
||||
Studio: Email will be sent to admin address when a user requests course creator
|
||||
privileges for Studio (edge only).
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
Student Views
|
||||
"""
|
||||
import datetime
|
||||
import feedparser
|
||||
import json
|
||||
@@ -27,6 +30,7 @@ from django_future.csrf import ensure_csrf_cookie
|
||||
from django.utils.http import cookie_date
|
||||
from django.utils.http import base36_to_int
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from ratelimitbackend.exceptions import RateLimitException
|
||||
|
||||
@@ -54,6 +58,10 @@ from courseware.access import has_access
|
||||
|
||||
from external_auth.models import ExternalAuthMap
|
||||
|
||||
from bulk_email.models import Optout
|
||||
|
||||
import track.views
|
||||
|
||||
from statsd import statsd
|
||||
from pytz import UTC
|
||||
|
||||
@@ -64,8 +72,7 @@ Article = namedtuple('Article', 'title url author image deck publication publish
|
||||
|
||||
|
||||
def csrf_token(context):
|
||||
''' A csrf token that can be included in a form.
|
||||
'''
|
||||
"""A csrf token that can be included in a form."""
|
||||
csrf_token = context.get('csrf_token', '')
|
||||
if csrf_token == 'NOTPROVIDED':
|
||||
return ''
|
||||
@@ -78,12 +85,12 @@ def csrf_token(context):
|
||||
# This means that it should always return the same thing for anon
|
||||
# users. (in particular, no switching based on query params allowed)
|
||||
def index(request, extra_context={}, user=None):
|
||||
'''
|
||||
"""
|
||||
Render the edX main page.
|
||||
|
||||
extra_context is used to allow immediate display of certain modal windows, eg signup,
|
||||
as used by external_auth.
|
||||
'''
|
||||
"""
|
||||
|
||||
# The course selection work is done in courseware.courses.
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
@@ -267,6 +274,8 @@ def dashboard(request):
|
||||
log.error("User {0} enrolled in non-existent course {1}"
|
||||
.format(user.username, enrollment.course_id))
|
||||
|
||||
course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
|
||||
|
||||
message = ""
|
||||
if not user.is_active:
|
||||
message = render_to_string('registration/activate_account_notice.html', {'email': user.email})
|
||||
@@ -294,6 +303,7 @@ def dashboard(request):
|
||||
pass
|
||||
|
||||
context = {'courses': courses,
|
||||
'course_optouts': course_optouts,
|
||||
'message': message,
|
||||
'external_auth_map': external_auth_map,
|
||||
'staff_access': staff_access,
|
||||
@@ -404,7 +414,7 @@ def accounts_login(request, error=""):
|
||||
# Need different levels of logging
|
||||
@ensure_csrf_cookie
|
||||
def login_user(request, error=""):
|
||||
''' AJAX request to log in the user. '''
|
||||
"""AJAX request to log in the user."""
|
||||
if 'email' not in request.POST or 'password' not in request.POST:
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'value': _('There was an error receiving your login information. Please email us.')})) # TODO: User error message
|
||||
@@ -487,11 +497,11 @@ def login_user(request, error=""):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def logout_user(request):
|
||||
'''
|
||||
"""
|
||||
HTTP request to log out the user. Redirects to marketing page.
|
||||
Deletes both the CSRF and sessionid cookies so the marketing
|
||||
site can determine the logged in state of the user
|
||||
'''
|
||||
"""
|
||||
# We do not log here, because we have a handler registered
|
||||
# to perform logging on successful logouts.
|
||||
logout(request)
|
||||
@@ -505,8 +515,7 @@ def logout_user(request):
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def change_setting(request):
|
||||
''' JSON call to change a profile setting: Right now, location
|
||||
'''
|
||||
"""JSON call to change a profile setting: Right now, location"""
|
||||
# TODO (vshnayder): location is no longer used
|
||||
up = UserProfile.objects.get(user=request.user) # request.user.profile_cache
|
||||
if 'location' in request.POST:
|
||||
@@ -574,10 +583,10 @@ def _do_create_account(post_vars):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def create_account(request, post_override=None):
|
||||
'''
|
||||
"""
|
||||
JSON call to create new edX account.
|
||||
Used by form in signup_modal.html, which is included into navigation.html
|
||||
'''
|
||||
"""
|
||||
js = {'success': False}
|
||||
|
||||
post_vars = post_override if post_override else request.POST
|
||||
@@ -811,10 +820,10 @@ def begin_exam_registration(request, course_id):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def create_exam_registration(request, post_override=None):
|
||||
'''
|
||||
"""
|
||||
JSON call to create a test center exam registration.
|
||||
Called by form in test_center_register.html
|
||||
'''
|
||||
"""
|
||||
post_vars = post_override if post_override else request.POST
|
||||
|
||||
# first determine if we need to create a new TestCenterUser, or if we are making any update
|
||||
@@ -967,8 +976,7 @@ def auto_auth(request):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def activate_account(request, key):
|
||||
''' When link in activation e-mail is clicked
|
||||
'''
|
||||
"""When link in activation e-mail is clicked"""
|
||||
r = Registration.objects.filter(activation_key=key)
|
||||
if len(r) == 1:
|
||||
user_logged_in = request.user.is_authenticated()
|
||||
@@ -1003,7 +1011,7 @@ def activate_account(request, key):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def password_reset(request):
|
||||
''' Attempts to send a password reset e-mail. '''
|
||||
""" Attempts to send a password reset e-mail. """
|
||||
if request.method != "POST":
|
||||
raise Http404
|
||||
|
||||
@@ -1025,9 +1033,9 @@ def password_reset_confirm_wrapper(
|
||||
uidb36=None,
|
||||
token=None,
|
||||
):
|
||||
''' A wrapper around django.contrib.auth.views.password_reset_confirm.
|
||||
""" A wrapper around django.contrib.auth.views.password_reset_confirm.
|
||||
Needed because we want to set the user as active at this step.
|
||||
'''
|
||||
"""
|
||||
# cribbed from django.contrib.auth.views.password_reset_confirm
|
||||
try:
|
||||
uid_int = base36_to_int(uidb36)
|
||||
@@ -1069,8 +1077,8 @@ def reactivation_email_for_user(user):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def change_email_request(request):
|
||||
''' AJAX call from the profile page. User wants a new e-mail.
|
||||
'''
|
||||
""" AJAX call from the profile page. User wants a new e-mail.
|
||||
"""
|
||||
## Make sure it checks for existing e-mail conflicts
|
||||
if not request.user.is_authenticated:
|
||||
raise Http404
|
||||
@@ -1125,9 +1133,9 @@ def change_email_request(request):
|
||||
@ensure_csrf_cookie
|
||||
@transaction.commit_manually
|
||||
def confirm_email_change(request, key):
|
||||
''' User requested a new e-mail. This is called when the activation
|
||||
""" User requested a new e-mail. This is called when the activation
|
||||
link is clicked. We confirm with the old e-mail, and update
|
||||
'''
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
pec = PendingEmailChange.objects.get(activation_key=key)
|
||||
@@ -1184,7 +1192,7 @@ def confirm_email_change(request, key):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def change_name_request(request):
|
||||
''' Log a request for a new name. '''
|
||||
""" Log a request for a new name. """
|
||||
if not request.user.is_authenticated:
|
||||
raise Http404
|
||||
|
||||
@@ -1208,7 +1216,7 @@ def change_name_request(request):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def pending_name_changes(request):
|
||||
''' Web page which allows staff to approve or reject name changes. '''
|
||||
""" Web page which allows staff to approve or reject name changes. """
|
||||
if not request.user.is_staff:
|
||||
raise Http404
|
||||
|
||||
@@ -1224,7 +1232,7 @@ def pending_name_changes(request):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def reject_name_change(request):
|
||||
''' JSON: Name change process. Course staff clicks 'reject' on a given name change '''
|
||||
""" JSON: Name change process. Course staff clicks 'reject' on a given name change """
|
||||
if not request.user.is_staff:
|
||||
raise Http404
|
||||
|
||||
@@ -1262,13 +1270,36 @@ def accept_name_change_by_id(id):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def accept_name_change(request):
|
||||
''' JSON: Name change process. Course staff clicks 'accept' on a given name change
|
||||
""" JSON: Name change process. Course staff clicks 'accept' on a given name change
|
||||
|
||||
We used this during the prototype but now we simply record name changes instead
|
||||
of manually approving them. Still keeping this around in case we want to go
|
||||
back to this approval method.
|
||||
'''
|
||||
"""
|
||||
if not request.user.is_staff:
|
||||
raise Http404
|
||||
|
||||
return accept_name_change_by_id(int(request.POST['id']))
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def change_email_settings(request):
|
||||
"""Modify logged-in user's setting for receiving emails from a course."""
|
||||
user = request.user
|
||||
|
||||
course_id = request.POST.get("course_id")
|
||||
receive_emails = request.POST.get("receive_emails")
|
||||
if receive_emails:
|
||||
optout_object = Optout.objects.filter(user=user, course_id=course_id)
|
||||
if optout_object:
|
||||
optout_object.delete()
|
||||
log.info(u"User {0} ({1}) opted in to receive emails from course {2}".format(user.username, user.email, course_id))
|
||||
track.views.server_track(request, "change-email-settings", {"receive_emails": "yes", "course": course_id}, page='dashboard')
|
||||
else:
|
||||
Optout.objects.get_or_create(user=user, course_id=course_id)
|
||||
log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id))
|
||||
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
27
common/lib/html_to_text.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Provides a function to convert html to plaintext."""
|
||||
import logging
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def html_to_text(html_message):
|
||||
"""
|
||||
Converts an html message to plaintext.
|
||||
Currently uses lynx in a subprocess; should be refactored to
|
||||
use something more pythonic.
|
||||
"""
|
||||
process = Popen(
|
||||
['lynx', '-stdin', '-display_charset=UTF-8', '-assume_charset=UTF-8', '-dump'],
|
||||
stdin=PIPE,
|
||||
stdout=PIPE
|
||||
)
|
||||
# use lynx to get plaintext
|
||||
(plaintext, err_from_stderr) = process.communicate(
|
||||
input=html_message.encode('utf-8')
|
||||
)
|
||||
|
||||
if err_from_stderr:
|
||||
log.info(err_from_stderr)
|
||||
|
||||
return plaintext
|
||||
0
lms/djangoapps/bulk_email/__init__.py
Normal file
61
lms/djangoapps/bulk_email/admin.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Django admin page for bulk email models
|
||||
"""
|
||||
from django.contrib import admin
|
||||
|
||||
from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate
|
||||
from bulk_email.forms import CourseEmailTemplateForm
|
||||
|
||||
|
||||
class CourseEmailAdmin(admin.ModelAdmin):
|
||||
"""Admin for course email."""
|
||||
readonly_fields = ('sender',)
|
||||
|
||||
|
||||
class OptoutAdmin(admin.ModelAdmin):
|
||||
"""Admin for optouts."""
|
||||
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
|
||||
101
lms/djangoapps/bulk_email/migrations/0001_initial.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'CourseEmail'
|
||||
db.create_table('bulk_email_courseemail', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('sender', self.gf('django.db.models.fields.related.ForeignKey')(default=1, to=orm['auth.User'], null=True, blank=True)),
|
||||
('hash', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)),
|
||||
('subject', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
|
||||
('html_message', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
|
||||
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('to', self.gf('django.db.models.fields.CharField')(default='myself', max_length=64)),
|
||||
))
|
||||
db.send_create_signal('bulk_email', ['CourseEmail'])
|
||||
|
||||
# Adding model 'Optout'
|
||||
db.create_table('bulk_email_optout', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
))
|
||||
db.send_create_signal('bulk_email', ['Optout'])
|
||||
|
||||
# Adding unique constraint on 'Optout', fields ['email', 'course_id']
|
||||
db.create_unique('bulk_email_optout', ['email', 'course_id'])
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'Optout', fields ['email', 'course_id']
|
||||
db.delete_unique('bulk_email_optout', ['email', 'course_id'])
|
||||
|
||||
# Deleting model 'CourseEmail'
|
||||
db.delete_table('bulk_email_courseemail')
|
||||
|
||||
# Deleting model 'Optout'
|
||||
db.delete_table('bulk_email_optout')
|
||||
|
||||
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'}),
|
||||
'hash': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': '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'}),
|
||||
'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'to': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'})
|
||||
},
|
||||
'bulk_email.optout': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'Optout'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Renaming field 'CourseEmail.to'
|
||||
db.rename_column('bulk_email_courseemail', 'to', 'to_option')
|
||||
|
||||
# Renaming field 'CourseEmail.hash'
|
||||
db.rename_column('bulk_email_courseemail', 'hash', 'slug')
|
||||
|
||||
# Adding field 'CourseEmail.text_message'
|
||||
db.add_column('bulk_email_courseemail', 'text_message',
|
||||
self.gf('django.db.models.fields.TextField')(null=True, blank=True),
|
||||
keep_default=False)
|
||||
|
||||
def backwards(self, orm):
|
||||
# Renaming field 'CourseEmail.to_option'
|
||||
db.rename_column('bulk_email_courseemail', 'to_option', 'to')
|
||||
|
||||
# Renaming field 'CourseEmail.slug'
|
||||
db.rename_column('bulk_email_courseemail', 'slug', 'hash')
|
||||
|
||||
# Deleting field 'CourseEmail.text_message'
|
||||
db.delete_column('bulk_email_courseemail', 'text_message')
|
||||
|
||||
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.optout': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'Optout'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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']
|
||||
91
lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
|
||||
# Adding field 'Optout.user'
|
||||
db.add_column('bulk_email_optout', 'user',
|
||||
self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True),
|
||||
keep_default=False)
|
||||
|
||||
# Removing unique constraint on 'Optout', fields ['course_id', 'email']
|
||||
db.delete_unique('bulk_email_optout', ['course_id', 'email'])
|
||||
|
||||
# Adding unique constraint on 'Optout', fields ['course_id', 'user']
|
||||
db.create_unique('bulk_email_optout', ['course_id', 'user_id'])
|
||||
|
||||
def backwards(self, orm):
|
||||
|
||||
# Removing unique constraint on 'Optout', fields ['course_id', 'user']
|
||||
db.delete_unique('bulk_email_optout', ['course_id', 'user_id'])
|
||||
|
||||
# Deleting field 'Optout.email'
|
||||
db.delete_column('bulk_email_optout', 'user_id')
|
||||
|
||||
# Creating unique constraint on 'Optout', fields ['course_id', 'email']
|
||||
db.create_unique('bulk_email_optout', ['course_id', 'email'])
|
||||
|
||||
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.optout': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'email': ('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,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.db import db
|
||||
from south.v2 import DataMigration
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
|
||||
class Migration(DataMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
|
||||
# forwards data migration to copy over existing emails to associated ids
|
||||
if not db.dry_run:
|
||||
for optout in orm.Optout.objects.all():
|
||||
try:
|
||||
user = orm['auth.User'].objects.get(email=optout.email)
|
||||
optout.user = user
|
||||
optout.save()
|
||||
except ObjectDoesNotExist:
|
||||
# if user is not found (because they have already changed their email)
|
||||
# then delete the optout, as it's no longer useful.
|
||||
optout.delete()
|
||||
|
||||
def backwards(self, orm):
|
||||
|
||||
# backwards data migration to copy over emails of students to old email slot
|
||||
if not db.dry_run:
|
||||
for optout in orm.Optout.objects.all():
|
||||
if optout.user is not None:
|
||||
optout.email = optout.user.email
|
||||
optout.save()
|
||||
|
||||
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.optout': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'email': ('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,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
|
||||
# Deleting field 'Optout.email'
|
||||
db.delete_column('bulk_email_optout', 'email')
|
||||
|
||||
def backwards(self, orm):
|
||||
|
||||
# Adding field 'Optout.email'
|
||||
db.add_column('bulk_email_optout', 'email',
|
||||
self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True),
|
||||
keep_default=False)
|
||||
|
||||
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.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,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
|
||||
0
lms/djangoapps/bulk_email/migrations/__init__.py
Normal file
155
lms/djangoapps/bulk_email/models.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Models for bulk email
|
||||
|
||||
WE'RE USING MIGRATIONS!
|
||||
|
||||
If you make changes to this model, be sure to create an appropriate migration
|
||||
file and check it in at the same time as your model changes. To do that,
|
||||
|
||||
1. Go to the edx-platform dir
|
||||
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/
|
||||
|
||||
"""
|
||||
import logging
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Email(models.Model):
|
||||
"""
|
||||
Abstract base class for common information for an email.
|
||||
"""
|
||||
sender = models.ForeignKey(User, default=1, blank=True, null=True)
|
||||
slug = models.CharField(max_length=128, db_index=True)
|
||||
subject = models.CharField(max_length=128, blank=True)
|
||||
html_message = models.TextField(null=True, blank=True)
|
||||
text_message = models.TextField(null=True, blank=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
modified = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta: # pylint: disable=C0111
|
||||
abstract = True
|
||||
|
||||
SEND_TO_MYSELF = 'myself'
|
||||
SEND_TO_STAFF = 'staff'
|
||||
SEND_TO_ALL = 'all'
|
||||
|
||||
|
||||
class CourseEmail(Email, models.Model):
|
||||
"""
|
||||
Stores information for an email to a course.
|
||||
"""
|
||||
# Three options for sending that we provide from the instructor dashboard:
|
||||
# * Myself: This sends an email to the staff member that is composing the email.
|
||||
#
|
||||
# * Staff and instructors: This sends an email to anyone in the staff group and
|
||||
# anyone in the instructor group
|
||||
#
|
||||
# * All: This sends an email to anyone enrolled in the course, with any role
|
||||
# (student, staff, or instructor)
|
||||
#
|
||||
TO_OPTIONS = (
|
||||
(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=SEND_TO_MYSELF)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.subject
|
||||
|
||||
|
||||
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)
|
||||
228
lms/djangoapps/bulk_email/tasks.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
This module contains celery task functions for handling the sending of bulk email
|
||||
to a course.
|
||||
"""
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
|
||||
from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User, Group
|
||||
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, 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, course_image_url
|
||||
|
||||
log = get_task_logger(__name__)
|
||||
|
||||
|
||||
@task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102
|
||||
def delegate_email_batches(email_id, 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.
|
||||
|
||||
Returns the number of batches (workers) kicked off.
|
||||
"""
|
||||
try:
|
||||
email_obj = 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, user_id], exc=exc)
|
||||
|
||||
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 == 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()
|
||||
instructor_grpname = _course_instructor_group_name(course.location)
|
||||
instructor_group, _ = Group.objects.get_or_create(name=instructor_grpname)
|
||||
instructor_qset = instructor_group.user_set.all()
|
||||
recipient_qset = staff_qset | instructor_qset
|
||||
|
||||
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
|
||||
recipient_qset = recipient_qset.distinct()
|
||||
else:
|
||||
log.error("Unexpected bulk email TO_OPTION found: %s", to_option)
|
||||
raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option))
|
||||
|
||||
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 _ 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']
|
||||
num_emails_this_query = len(recipient_sublist)
|
||||
num_tasks_this_query = int(math.ceil(float(num_emails_this_query) / float(settings.EMAILS_PER_TASK)))
|
||||
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,
|
||||
image_url,
|
||||
False
|
||||
)
|
||||
num_workers += num_tasks_this_query
|
||||
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, image_url, throttle=False):
|
||||
"""
|
||||
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:
|
||||
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__in=[i['pk'] for i in to_list])
|
||||
.values_list('user__email', flat=True))
|
||||
|
||||
optouts = set(optouts)
|
||||
num_optout = len(optouts)
|
||||
|
||||
to_list = filter(lambda x: x['email'] not in 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_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']
|
||||
|
||||
# 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)
|
||||
|
||||
# Create email:
|
||||
email_msg = EmailMultiAlternatives(
|
||||
subject,
|
||||
plaintext_msg,
|
||||
from_addr,
|
||||
[email],
|
||||
connection=connection
|
||||
)
|
||||
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:
|
||||
time.sleep(0.2)
|
||||
|
||||
try:
|
||||
connection.send_messages([email_msg])
|
||||
log.info('Email with id %s sent to %s', email_id, email)
|
||||
num_sent += 1
|
||||
except SMTPDataError as exc:
|
||||
# According to SMTP spec, we'll retry error codes in the 4xx range. 5xx range indicates hard failure
|
||||
if exc.smtp_code >= 400 and exc.smtp_code < 500:
|
||||
# This will cause the outer handler to catch the exception and retry the entire task
|
||||
raise exc
|
||||
else:
|
||||
# This will fall through and not retry the message, since it will be popped
|
||||
log.warning('Email with id %s not delivered to %s due to error %s', email_id, email, exc.smtp_error)
|
||||
num_error += 1
|
||||
|
||||
to_list.pop()
|
||||
|
||||
connection.close()
|
||||
return course_email_result(num_sent, num_error, num_optout)
|
||||
|
||||
except (SMTPDataError, SMTPConnectError, SMTPServerDisconnected) as exc:
|
||||
# Error caught here cause the email to be retried. The entire task is actually retried without popping the list
|
||||
# Reasoning is that all of these errors may be temporary condition.
|
||||
log.warning('Email with id %d not delivered due to temporary error %s, retrying send to %d recipients',
|
||||
email_id, exc, len(to_list))
|
||||
raise course_email.retry(
|
||||
arg=[
|
||||
email_id,
|
||||
to_list,
|
||||
course_title,
|
||||
course_url,
|
||||
image_url,
|
||||
current_task.request.retries > 0
|
||||
],
|
||||
exc=exc,
|
||||
countdown=(2 ** current_task.request.retries) * 15
|
||||
)
|
||||
except:
|
||||
log.exception('Email with id %d caused course_email task to fail with uncaught exception. To list: %s',
|
||||
email_id,
|
||||
[i['email'] for i in to_list])
|
||||
raise
|
||||
|
||||
|
||||
# This string format code is wrapped in this function to allow mocking for a unit test
|
||||
def course_email_result(num_sent, num_error, num_optout):
|
||||
"""Return the formatted result of course_email sending."""
|
||||
return "Sent {0}, Fail {1}, Optout {2}".format(num_sent, num_error, num_optout)
|
||||
0
lms/djangoapps/bulk_email/tests/__init__.py
Normal file
88
lms/djangoapps/bulk_email/tests/fake_smtp.py
Executable file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Fake SMTP Server used for testing error handling for sending email.
|
||||
We could have mocked smptlib to raise connection errors, but this simulates
|
||||
connection errors from an SMTP server.
|
||||
"""
|
||||
import smtpd
|
||||
import socket
|
||||
import asyncore
|
||||
import asynchat
|
||||
import errno
|
||||
|
||||
|
||||
class FakeSMTPChannel(smtpd.SMTPChannel):
|
||||
"""
|
||||
A fake SMTPChannel for sending fake error response through socket.
|
||||
This causes smptlib to raise an SMTPConnectError.
|
||||
|
||||
Adapted from http://hg.python.org/cpython/file/2.7/Lib/smtpd.py
|
||||
"""
|
||||
# Disable pylint warnings that arise from subclassing SMTPChannel
|
||||
# and calling init -- overriding SMTPChannel's init to return error
|
||||
# message but keeping the rest of the class.
|
||||
# pylint: disable=W0231, W0233
|
||||
def __init__(self, server, conn, addr):
|
||||
asynchat.async_chat.__init__(self, conn)
|
||||
self.__server = server
|
||||
self.__conn = conn
|
||||
self.__addr = addr
|
||||
self.__line = []
|
||||
self.__state = self.COMMAND
|
||||
self.__greeting = 0
|
||||
self.__mailfrom = None
|
||||
self.__rcpttos = []
|
||||
self.__data = ''
|
||||
self.__fqdn = socket.getfqdn()
|
||||
try:
|
||||
self.__peer = conn.getpeername()
|
||||
except socket.error, err:
|
||||
# a race condition may occur if the other end is closing
|
||||
# before we can get the peername
|
||||
self.close()
|
||||
if err[0] != errno.ENOTCONN:
|
||||
raise
|
||||
return
|
||||
self.push('421 SMTP Server error: too many concurrent sessions, please try again later.')
|
||||
self.set_terminator('\r\n')
|
||||
|
||||
|
||||
class FakeSMTPServer(smtpd.SMTPServer):
|
||||
"""A fake SMTP server for generating different smptlib exceptions."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
smtpd.SMTPServer.__init__(self, *args, **kwargs)
|
||||
self.errtype = None
|
||||
self.response = None
|
||||
|
||||
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.response = response
|
||||
|
||||
def handle_accept(self):
|
||||
if self.errtype == "DISCONN":
|
||||
self.accept()
|
||||
elif self.errtype == "CONN":
|
||||
pair = self.accept()
|
||||
if pair is not None:
|
||||
conn, addr = pair
|
||||
_channel = FakeSMTPChannel(self, conn, addr)
|
||||
else:
|
||||
smtpd.SMTPServer.handle_accept(self)
|
||||
|
||||
def process_message(self, *_args, **_kwargs):
|
||||
if self.errtype == "DATA":
|
||||
# After failing on the first email, succeed on the rest.
|
||||
self.errtype = None
|
||||
return self.response
|
||||
else:
|
||||
return None
|
||||
|
||||
def serve_forever(self):
|
||||
"""Start the server running until close() is called on the server."""
|
||||
asyncore.loop()
|
||||
47
lms/djangoapps/bulk_email/tests/smtp_server_thread.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
def __init__(self, host, port):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.is_ready = threading.Event()
|
||||
self.error = None
|
||||
self.server = None
|
||||
super(FakeSMTPServerThread, self).__init__()
|
||||
|
||||
def start(self):
|
||||
self.daemon = True
|
||||
super(FakeSMTPServerThread, self).start()
|
||||
self.is_ready.wait()
|
||||
if 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.
|
||||
"""
|
||||
try:
|
||||
self.server = FakeSMTPServer((self.host, self.port), None)
|
||||
self.is_ready.set()
|
||||
self.server.serve_forever()
|
||||
except Exception, exc: # pylint: disable=W0703
|
||||
self.error = exc
|
||||
self.is_ready.set()
|
||||
121
lms/djangoapps/bulk_email/tests/test_course_optout.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from mock import patch
|
||||
|
||||
|
||||
@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()
|
||||
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):
|
||||
"""
|
||||
Undo all patches.
|
||||
"""
|
||||
patch.stopall()
|
||||
|
||||
def navigate_to_email_view(self):
|
||||
"""Navigate to the instructor dash's email view"""
|
||||
# Pull up email view on instructor dashboard
|
||||
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(url)
|
||||
email_link = '<a href="#" onclick="goto(\'Email\')" class="None">Email</a>'
|
||||
# If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False
|
||||
self.assertTrue(email_link in response.content)
|
||||
|
||||
# Select the Email view of the instructor dash
|
||||
session = self.client.session
|
||||
session['idash_mode'] = 'Email'
|
||||
session.save()
|
||||
response = self.client.get(url)
|
||||
selected_email_link = '<a href="#" onclick="goto(\'Email\')" class="selectedmode">Email</a>'
|
||||
self.assertTrue(selected_email_link in response.content)
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_optout_course(self):
|
||||
"""
|
||||
Make sure student does not receive course email after opting out.
|
||||
"""
|
||||
url = reverse('change_email_settings')
|
||||
# This is a checkbox, so on the post of opting out (that is, an Un-check of the box),
|
||||
# the Post that is sent will not contain 'receive_emails'
|
||||
response = self.client.post(url, {'course_id': self.course.id})
|
||||
self.assertEquals(json.loads(response.content), {'success': True})
|
||||
|
||||
self.client.logout()
|
||||
|
||||
self.client.login(username=self.instructor.username, password="test")
|
||||
self.navigate_to_email_view()
|
||||
|
||||
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': 'test subject for all',
|
||||
'message': 'test message for all'
|
||||
}
|
||||
response = self.client.post(url, test_email)
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
# Assert that self.student.email not in mail.to, outbox should be empty
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_optin_course(self):
|
||||
"""
|
||||
Make sure student receives course email after opting in.
|
||||
"""
|
||||
url = reverse('change_email_settings')
|
||||
response = self.client.post(url, {'course_id': self.course.id, 'receive_emails': 'on'})
|
||||
self.assertEquals(json.loads(response.content), {'success': True})
|
||||
|
||||
self.client.logout()
|
||||
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.student, self.course.id))
|
||||
|
||||
self.client.login(username=self.instructor.username, password="test")
|
||||
self.navigate_to_email_view()
|
||||
|
||||
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': 'test subject for all',
|
||||
'message': 'test message for all'
|
||||
}
|
||||
response = self.client.post(url, test_email)
|
||||
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
# Assert that self.student.email in mail.to
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(len(mail.outbox[0].to), 1)
|
||||
self.assertEquals(mail.outbox[0].to[0], self.student.email)
|
||||
300
lms/djangoapps/bulk_email/tests/test_email.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Unit tests for sending course email
|
||||
"""
|
||||
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
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from bulk_email.tasks import delegate_email_batches, course_email
|
||||
from bulk_email.models import CourseEmail, Optout
|
||||
|
||||
from mock import patch
|
||||
|
||||
STAFF_COUNT = 3
|
||||
STUDENT_COUNT = 10
|
||||
LARGE_NUM_EMAILS = 137
|
||||
|
||||
|
||||
class MockCourseEmailResult(object):
|
||||
"""
|
||||
A small closure-like class to keep count of emails sent over all tasks, recorded
|
||||
by mock object side effects
|
||||
"""
|
||||
emails_sent = 0
|
||||
|
||||
def get_mock_course_email_result(self):
|
||||
"""Wrapper for mock email function."""
|
||||
def mock_course_email_result(sent, failed, output, **kwargs): # pylint: disable=W0613
|
||||
"""Increments count of number of emails sent."""
|
||||
self.emails_sent += sent
|
||||
return True
|
||||
return mock_course_email_result
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestEmailSendFromDashboard(ModuleStoreTestCase):
|
||||
"""
|
||||
Test that emails send correctly.
|
||||
"""
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create()
|
||||
self.instructor = UserFactory.create(username="instructor", email="robot+instructor@edx.org")
|
||||
# Create instructor group for course
|
||||
instructor_group = GroupFactory.create(name="instructor_MITx/999/Robot_Super_Course")
|
||||
instructor_group.user_set.add(self.instructor)
|
||||
|
||||
# Create staff
|
||||
self.staff = [UserFactory() for _ in xrange(STAFF_COUNT)]
|
||||
staff_group = GroupFactory()
|
||||
for staff in self.staff:
|
||||
staff_group.user_set.add(staff) # pylint: disable=E1101
|
||||
|
||||
# Create students
|
||||
self.students = [UserFactory() for _ in xrange(STUDENT_COUNT)]
|
||||
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
|
||||
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
|
||||
response = self.client.get(self.url)
|
||||
email_link = '<a href="#" onclick="goto(\'Email\')" class="None">Email</a>'
|
||||
# If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False
|
||||
self.assertTrue(email_link in response.content)
|
||||
|
||||
# Select the Email view of the instructor dash
|
||||
session = self.client.session
|
||||
session['idash_mode'] = 'Email'
|
||||
session.save()
|
||||
response = self.client.get(self.url)
|
||||
selected_email_link = '<a href="#" onclick="goto(\'Email\')" class="selectedmode">Email</a>'
|
||||
self.assertTrue(selected_email_link in response.content)
|
||||
|
||||
def tearDown(self):
|
||||
"""
|
||||
Undo all patches.
|
||||
"""
|
||||
patch.stopall()
|
||||
|
||||
def test_send_to_self(self):
|
||||
"""
|
||||
Make sure email send to myself goes to myself.
|
||||
"""
|
||||
# Now we know we have pulled up the instructor dash's email view
|
||||
# (in the setUp method), we can test sending an email.
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'myself',
|
||||
'subject': 'test subject for myself',
|
||||
'message': 'test message for myself'
|
||||
}
|
||||
response = self.client.post(self.url, test_email)
|
||||
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(len(mail.outbox[0].to), 1)
|
||||
self.assertEquals(mail.outbox[0].to[0], self.instructor.email)
|
||||
self.assertEquals(
|
||||
mail.outbox[0].subject,
|
||||
'[' + self.course.display_name + ']' + ' test subject for myself'
|
||||
)
|
||||
|
||||
def test_send_to_staff(self):
|
||||
"""
|
||||
Make sure email send to staff and instructors goes there.
|
||||
"""
|
||||
# Now we know we have pulled up the instructor dash's email view
|
||||
# (in the setUp method), we can test sending an email.
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'staff',
|
||||
'subject': 'test subject for staff',
|
||||
'message': 'test message for subject'
|
||||
}
|
||||
response = self.client.post(self.url, test_email)
|
||||
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
# the 1 is for the instructor in this test and others
|
||||
self.assertEquals(len(mail.outbox), 1 + len(self.staff))
|
||||
self.assertItemsEqual(
|
||||
[e.to[0] for e in mail.outbox],
|
||||
[self.instructor.email] + [s.email for s in self.staff]
|
||||
)
|
||||
|
||||
def test_send_to_all(self):
|
||||
"""
|
||||
Make sure email send to all goes there.
|
||||
"""
|
||||
# Now we know we have pulled up the instructor dash's email view
|
||||
# (in the setUp method), we can test sending an email.
|
||||
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': 'test subject for all',
|
||||
'message': 'test message for all'
|
||||
}
|
||||
response = self.client.post(self.url, test_email)
|
||||
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students))
|
||||
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]
|
||||
)
|
||||
|
||||
def test_unicode_subject_send_to_all(self):
|
||||
"""
|
||||
Make sure email (with Unicode characters) send to all goes there.
|
||||
"""
|
||||
# Now we know we have pulled up the instructor dash's email view
|
||||
# (in the setUp method), we can test sending an email.
|
||||
|
||||
uni_subject = u'téśt śúbjéćt főŕ áĺĺ'
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': uni_subject,
|
||||
'message': 'test message for all'
|
||||
}
|
||||
response = self.client.post(self.url, test_email)
|
||||
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students))
|
||||
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]
|
||||
)
|
||||
self.assertEquals(
|
||||
mail.outbox[0].subject,
|
||||
'[' + self.course.display_name + '] ' + uni_subject
|
||||
)
|
||||
|
||||
def test_unicode_message_send_to_all(self):
|
||||
"""
|
||||
Make sure email (with Unicode characters) send to all goes there.
|
||||
"""
|
||||
# Now we know we have pulled up the instructor dash's email view
|
||||
# (in the setUp method), we can test sending an email.
|
||||
|
||||
uni_message = u'ẗëṡẗ ṁëṡṡäġë ḟöṛ äḷḷ イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ fоѓ аll'
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': 'test subject for all',
|
||||
'message': uni_message
|
||||
}
|
||||
response = self.client.post(self.url, test_email)
|
||||
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students))
|
||||
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]
|
||||
)
|
||||
|
||||
message_body = mail.outbox[0].body
|
||||
self.assertIn(uni_message, message_body)
|
||||
|
||||
def test_unicode_students_send_to_all(self):
|
||||
"""
|
||||
Make sure email (with Unicode characters) send to all goes there.
|
||||
"""
|
||||
# Now we know we have pulled up the instructor dash's email view
|
||||
# (in the setUp method), we can test sending an email.
|
||||
|
||||
# Create a student with Unicode in their first & last names
|
||||
unicode_user = UserFactory(first_name=u'Ⓡⓞⓑⓞⓣ', last_name=u'ՇﻉรՇ')
|
||||
CourseEnrollmentFactory.create(user=unicode_user, course_id=self.course.id)
|
||||
self.students.append(unicode_user)
|
||||
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': 'test subject for all',
|
||||
'message': 'test message for all'
|
||||
}
|
||||
response = self.client.post(self.url, test_email)
|
||||
|
||||
self.assertContains(response, "Your email was successfully queued for sending.")
|
||||
|
||||
self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students))
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
@override_settings(EMAILS_PER_TASK=3, EMAILS_PER_QUERY=7)
|
||||
@patch('bulk_email.tasks.course_email_result')
|
||||
def test_chunked_queries_send_numerous_emails(self, email_mock):
|
||||
"""
|
||||
Test sending a large number of emails, to test the chunked querying
|
||||
"""
|
||||
mock_factory = MockCourseEmailResult()
|
||||
email_mock.side_effect = mock_factory.get_mock_course_email_result()
|
||||
added_users = []
|
||||
for _ in xrange(LARGE_NUM_EMAILS):
|
||||
user = UserFactory()
|
||||
added_users.append(user)
|
||||
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
|
||||
|
||||
optouts = []
|
||||
for i in [1, 3, 9, 10, 18]: # 5 random optouts
|
||||
user = added_users[i]
|
||||
optouts.append(user)
|
||||
optout = Optout(user=user, course_id=self.course.id)
|
||||
optout.save()
|
||||
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': 'test subject for all',
|
||||
'message': 'test message for all'
|
||||
}
|
||||
response = self.client.post(self.url, test_email)
|
||||
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))
|
||||
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)
|
||||
class TestEmailSendExceptions(ModuleStoreTestCase):
|
||||
"""
|
||||
Test that exceptions are handled correctly.
|
||||
"""
|
||||
|
||||
def test_get_course_exc(self):
|
||||
# Make sure delegate_email_batches handles Http404 exception from get_course_by_id.
|
||||
with self.assertRaises(Exception):
|
||||
delegate_email_batches("_", "_", "blah/blah/blah", "_", "_")
|
||||
|
||||
def test_no_course_email_obj(self):
|
||||
# Make sure course_email handles CourseEmail.DoesNotExist exception.
|
||||
with self.assertRaises(CourseEmail.DoesNotExist):
|
||||
course_email(101, [], "_", "_", "_", False)
|
||||
218
lms/djangoapps/bulk_email/tests/test_err_handling.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
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
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
|
||||
|
||||
from bulk_email.models import CourseEmail
|
||||
from bulk_email.tasks import delegate_email_batches
|
||||
from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread
|
||||
|
||||
from mock import patch, Mock
|
||||
from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError
|
||||
|
||||
TEST_SMTP_PORT = 1025
|
||||
|
||||
|
||||
class EmailTestException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@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()
|
||||
self.instructor = AdminFactory.create()
|
||||
self.client.login(username=self.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()
|
||||
|
||||
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
|
||||
|
||||
def tearDown(self):
|
||||
self.smtp_server_thread.stop()
|
||||
patch.stopall()
|
||||
|
||||
@patch('bulk_email.tasks.course_email.retry')
|
||||
def test_data_err_retry(self, retry):
|
||||
"""
|
||||
Test that celery handles transient SMTPDataErrors by retrying.
|
||||
"""
|
||||
self.smtp_server_thread.server.set_errtype(
|
||||
"DATA",
|
||||
"454 Throttling failure: Daily message quota exceeded."
|
||||
)
|
||||
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'myself',
|
||||
'subject': 'test subject for myself',
|
||||
'message': 'test message for myself'
|
||||
}
|
||||
self.client.post(self.url, test_email)
|
||||
|
||||
# Test that we retry upon hitting a 4xx error
|
||||
self.assertTrue(retry.called)
|
||||
(_, kwargs) = retry.call_args
|
||||
exc = kwargs['exc']
|
||||
self.assertTrue(type(exc) == SMTPDataError)
|
||||
|
||||
@patch('bulk_email.tasks.course_email_result')
|
||||
@patch('bulk_email.tasks.course_email.retry')
|
||||
def test_data_err_fail(self, retry, result):
|
||||
"""
|
||||
Test that celery handles permanent SMTPDataErrors by failing and not retrying.
|
||||
"""
|
||||
self.smtp_server_thread.server.set_errtype(
|
||||
"DATA",
|
||||
"554 Message rejected: Email address is not verified."
|
||||
)
|
||||
|
||||
students = [UserFactory() for _ in xrange(settings.EMAILS_PER_TASK)]
|
||||
for student in students:
|
||||
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
|
||||
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'all',
|
||||
'subject': 'test subject for all',
|
||||
'message': 'test message for all'
|
||||
}
|
||||
self.client.post(self.url, test_email)
|
||||
|
||||
# We shouldn't retry when hitting a 5xx error
|
||||
self.assertFalse(retry.called)
|
||||
# Test that after the rejected email, the rest still successfully send
|
||||
((sent, fail, optouts), _) = result.call_args
|
||||
self.assertEquals(optouts, 0)
|
||||
self.assertEquals(fail, 1)
|
||||
self.assertEquals(sent, settings.EMAILS_PER_TASK - 1)
|
||||
|
||||
@patch('bulk_email.tasks.course_email.retry')
|
||||
def test_disconn_err_retry(self, retry):
|
||||
"""
|
||||
Test that celery handles SMTPServerDisconnected by retrying.
|
||||
"""
|
||||
self.smtp_server_thread.server.set_errtype(
|
||||
"DISCONN",
|
||||
"Server disconnected, please try again later."
|
||||
)
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'myself',
|
||||
'subject': 'test subject for myself',
|
||||
'message': 'test message for myself'
|
||||
}
|
||||
self.client.post(self.url, test_email)
|
||||
|
||||
self.assertTrue(retry.called)
|
||||
(_, kwargs) = retry.call_args
|
||||
exc = kwargs['exc']
|
||||
self.assertTrue(type(exc) == SMTPServerDisconnected)
|
||||
|
||||
@patch('bulk_email.tasks.course_email.retry')
|
||||
def test_conn_err_retry(self, retry):
|
||||
"""
|
||||
Test that celery handles SMTPConnectError by retrying.
|
||||
"""
|
||||
# SMTP reply is already specified in fake SMTP Channel created
|
||||
self.smtp_server_thread.server.set_errtype("CONN")
|
||||
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'myself',
|
||||
'subject': 'test subject for myself',
|
||||
'message': 'test message for myself'
|
||||
}
|
||||
self.client.post(self.url, test_email)
|
||||
|
||||
self.assertTrue(retry.called)
|
||||
(_, kwargs) = retry.call_args
|
||||
exc = kwargs['exc']
|
||||
self.assertTrue(type(exc) == SMTPConnectError)
|
||||
|
||||
@patch('bulk_email.tasks.course_email_result')
|
||||
@patch('bulk_email.tasks.course_email.retry')
|
||||
@patch('bulk_email.tasks.log')
|
||||
@patch('bulk_email.tasks.get_connection', Mock(return_value=EmailTestException))
|
||||
def test_general_exception(self, mock_log, retry, result):
|
||||
"""
|
||||
Tests the if the error is not SMTP-related, we log and reraise
|
||||
"""
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'to_option': 'myself',
|
||||
'subject': 'test subject for myself',
|
||||
'message': 'test message for myself'
|
||||
}
|
||||
# For some reason (probably the weirdness of testing with celery tasks) assertRaises doesn't work here
|
||||
# so we assert on the arguments of log.exception
|
||||
self.client.post(self.url, test_email)
|
||||
((log_str, email_id, to_list), _) = mock_log.exception.call_args
|
||||
self.assertTrue(mock_log.exception.called)
|
||||
self.assertIn('caused course_email task to fail with uncaught exception.', log_str)
|
||||
self.assertEqual(email_id, 1)
|
||||
self.assertEqual(to_list, [self.instructor.email])
|
||||
self.assertFalse(retry.called)
|
||||
self.assertFalse(result.called)
|
||||
|
||||
@patch('bulk_email.tasks.course_email_result')
|
||||
@patch('bulk_email.tasks.delegate_email_batches.retry')
|
||||
@patch('bulk_email.tasks.log')
|
||||
def test_nonexist_email(self, mock_log, retry, result):
|
||||
"""
|
||||
Tests retries when the email doesn't exist
|
||||
"""
|
||||
delegate_email_batches.delay(-1, self.instructor.id)
|
||||
((log_str, email_id, num_retries), _) = mock_log.warning.call_args
|
||||
self.assertTrue(mock_log.warning.called)
|
||||
self.assertIn('Failed to get CourseEmail with id', log_str)
|
||||
self.assertEqual(email_id, -1)
|
||||
self.assertTrue(retry.called)
|
||||
self.assertFalse(result.called)
|
||||
|
||||
@patch('bulk_email.tasks.log')
|
||||
def test_nonexist_course(self, mock_log):
|
||||
"""
|
||||
Tests exception when the course in the email doesn't exist
|
||||
"""
|
||||
email = CourseEmail(course_id="I/DONT/EXIST")
|
||||
email.save()
|
||||
delegate_email_batches.delay(email.id, self.instructor.id)
|
||||
((log_str, _), _) = mock_log.exception.call_args
|
||||
self.assertTrue(mock_log.exception.called)
|
||||
self.assertIn('get_course_by_id failed:', log_str)
|
||||
|
||||
@patch('bulk_email.tasks.log')
|
||||
def test_nonexist_to_option(self, mock_log):
|
||||
"""
|
||||
Tests exception when the to_option in the email doesn't exist
|
||||
"""
|
||||
email = CourseEmail(course_id=self.course.id, to_option="IDONTEXIST")
|
||||
email.save()
|
||||
delegate_email_batches.delay(email.id, self.instructor.id)
|
||||
((log_str, opt_str), _) = mock_log.error.call_args
|
||||
self.assertTrue(mock_log.error.called)
|
||||
self.assertIn('Unexpected bulk email TO_OPTION found', log_str)
|
||||
self.assertEqual("IDONTEXIST", opt_str)
|
||||
142
lms/djangoapps/instructor/tests/test_legacy_email.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Unit tests for email feature flag in instructor dashboard
|
||||
and student dashboard. Additionally tests that bulk email
|
||||
is always disabled for non-Mongo backed courses, regardless
|
||||
of email feature flag.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore import XML_MODULESTORE_TYPE
|
||||
|
||||
from mock import patch
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestInstructorDashboardEmailView(ModuleStoreTestCase):
|
||||
"""
|
||||
Check for email view displayed with flag
|
||||
"""
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create()
|
||||
|
||||
# Create instructor account
|
||||
instructor = AdminFactory.create()
|
||||
self.client.login(username=instructor.username, password="test")
|
||||
|
||||
# URL for instructor dash
|
||||
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
|
||||
# URL for email view
|
||||
self.email_link = '<a href="#" onclick="goto(\'Email\')" class="None">Email</a>'
|
||||
|
||||
def tearDown(self):
|
||||
"""
|
||||
Undo all patches.
|
||||
"""
|
||||
patch.stopall()
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_email_flag_true(self):
|
||||
# Assert that the URL for the email view is in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertTrue(self.email_link in response.content)
|
||||
|
||||
# Select the Email view of the instructor dash
|
||||
session = self.client.session
|
||||
session['idash_mode'] = 'Email'
|
||||
session.save()
|
||||
response = self.client.get(self.url)
|
||||
|
||||
# Ensure we've selected the view properly and that the send_to field is present.
|
||||
selected_email_link = '<a href="#" onclick="goto(\'Email\')" class="selectedmode">Email</a>'
|
||||
self.assertTrue(selected_email_link in response.content)
|
||||
send_to_label = '<label for="id_to">Send to:</label>'
|
||||
self.assertTrue(send_to_label in response.content)
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
|
||||
def test_email_flag_false(self):
|
||||
# Assert that the URL for the email view is not in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertFalse(self.email_link in response.content)
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_email_flag_true_xml_store(self):
|
||||
# If the enable email setting is enabled, but this is an XML backed course,
|
||||
# the email view shouldn't be available on the instructor dashboard.
|
||||
|
||||
# The course factory uses a MongoModuleStore backing, so patch the
|
||||
# `get_modulestore_type` method to pretend to be XML-backed.
|
||||
# This is OK; we're simply testing that the `is_mongo_modulestore_type` flag
|
||||
# in `instructor/views/legacy.py` is doing the correct thing.
|
||||
|
||||
with patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_modulestore_type') as mock_modulestore:
|
||||
mock_modulestore.return_value = XML_MODULESTORE_TYPE
|
||||
|
||||
# Assert that the URL for the email view is not in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertFalse(self.email_link in response.content)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestStudentDashboardEmailView(ModuleStoreTestCase):
|
||||
"""
|
||||
Check for email view displayed with flag
|
||||
"""
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create()
|
||||
|
||||
# Create student account
|
||||
student = UserFactory.create()
|
||||
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
|
||||
self.client.login(username=student.username, password="test")
|
||||
|
||||
# URL for dashboard
|
||||
self.url = reverse('dashboard')
|
||||
# URL for email settings modal
|
||||
self.email_modal_link = (('<a href="#email-settings-modal" class="email-settings" rel="leanModal" '
|
||||
'data-course-id="{0}/{1}/{2}" data-course-number="{1}" '
|
||||
'data-optout="False">Email Settings</a>')
|
||||
.format(self.course.org,
|
||||
self.course.number,
|
||||
self.course.display_name.replace(' ', '_')))
|
||||
|
||||
def tearDown(self):
|
||||
"""
|
||||
Undo all patches.
|
||||
"""
|
||||
patch.stopall()
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_email_flag_true(self):
|
||||
# Assert that the URL for the email view is in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertTrue(self.email_modal_link in response.content)
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
|
||||
def test_email_flag_false(self):
|
||||
# Assert that the URL for the email view is not in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertFalse(self.email_modal_link in response.content)
|
||||
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_email_flag_true_xml_store(self):
|
||||
# If the enable email setting is enabled, but this is an XML backed course,
|
||||
# the email view shouldn't be available on the instructor dashboard.
|
||||
|
||||
# The course factory uses a MongoModuleStore backing, so patch the
|
||||
# `get_modulestore_type` method to pretend to be XML-backed.
|
||||
# This is OK; we're simply testing that the `is_mongo_modulestore_type` flag
|
||||
# in `instructor/views/legacy.py` is doing the correct thing.
|
||||
|
||||
with patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_modulestore_type') as mock_modulestore:
|
||||
mock_modulestore.return_value = XML_MODULESTORE_TYPE
|
||||
|
||||
# Assert that the URL for the email view is not in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertFalse(self.email_modal_link in response.content)
|
||||
@@ -23,9 +23,12 @@ from django.core.urlresolvers import reverse
|
||||
from django.core.mail import send_mail
|
||||
from django.utils import timezone
|
||||
|
||||
from xmodule_modifiers import wrap_xmodule
|
||||
import xmodule.graders as xmgraders
|
||||
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
|
||||
from courseware import grades
|
||||
from courseware.access import (has_access, get_access_group_name,
|
||||
@@ -51,6 +54,10 @@ import track.views
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
|
||||
|
||||
from bulk_email.models import CourseEmail
|
||||
from html_to_text import html_to_text
|
||||
from bulk_email import tasks
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# internal commands for managing forum roles:
|
||||
@@ -58,11 +65,11 @@ FORUM_ROLE_ADD = 'add'
|
||||
FORUM_ROLE_REMOVE = 'remove'
|
||||
|
||||
|
||||
def split_by_comma_and_whitespace(s):
|
||||
def split_by_comma_and_whitespace(a_str):
|
||||
"""
|
||||
Return string s, split by , or whitespace
|
||||
Return string a_str, split by , or whitespace
|
||||
"""
|
||||
return re.split(r'[\s,]', s)
|
||||
return re.split(r'[\s,]', a_str)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -76,6 +83,11 @@ def instructor_dashboard(request, course_id):
|
||||
forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR)
|
||||
|
||||
msg = ''
|
||||
email_msg = ''
|
||||
email_to_option = None
|
||||
email_subject = None
|
||||
html_message = ''
|
||||
show_email_tab = False
|
||||
problems = []
|
||||
plots = []
|
||||
datatable = {}
|
||||
@@ -111,13 +123,13 @@ def instructor_dashboard(request, course_id):
|
||||
datatable['data'] = data
|
||||
return datatable
|
||||
|
||||
def return_csv(fn, datatable, fp=None):
|
||||
def return_csv(func, datatable, file_pointer=None):
|
||||
"""Outputs a CSV file from the contents of a datatable."""
|
||||
if fp is None:
|
||||
if file_pointer is None:
|
||||
response = HttpResponse(mimetype='text/csv')
|
||||
response['Content-Disposition'] = 'attachment; filename={0}'.format(fn)
|
||||
response['Content-Disposition'] = 'attachment; filename={0}'.format(func)
|
||||
else:
|
||||
response = fp
|
||||
response = file_pointer
|
||||
writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
|
||||
writer.writerow(datatable['header'])
|
||||
for datarow in datatable['data']:
|
||||
@@ -266,11 +278,11 @@ def instructor_dashboard(request, course_id):
|
||||
msg += '<font color="red">Failed to create a background task for rescoring "{0}".</font>'.format(problem_url)
|
||||
else:
|
||||
track.views.server_track(request, "rescore-all-submissions", {"problem": problem_url, "course": course_id}, page="idashboard")
|
||||
except ItemNotFoundError as e:
|
||||
except ItemNotFoundError as err:
|
||||
msg += '<font color="red">Failed to create a background task for rescoring "{0}": problem not found.</font>'.format(problem_url)
|
||||
except Exception as e:
|
||||
log.error("Encountered exception from rescore: {0}".format(e))
|
||||
msg += '<font color="red">Failed to create a background task for rescoring "{0}": {1}.</font>'.format(problem_url, e.message)
|
||||
except Exception as err:
|
||||
log.error("Encountered exception from rescore: {0}".format(err))
|
||||
msg += '<font color="red">Failed to create a background task for rescoring "{0}": {1}.</font>'.format(problem_url, err.message)
|
||||
|
||||
elif "Reset ALL students' attempts" in action:
|
||||
problem_urlname = request.POST.get('problem_for_all_students', '')
|
||||
@@ -281,12 +293,12 @@ def instructor_dashboard(request, course_id):
|
||||
msg += '<font color="red">Failed to create a background task for resetting "{0}".</font>'.format(problem_url)
|
||||
else:
|
||||
track.views.server_track(request, "reset-all-attempts", {"problem": problem_url, "course": course_id}, page="idashboard")
|
||||
except ItemNotFoundError as e:
|
||||
log.error('Failure to reset: unknown problem "{0}"'.format(e))
|
||||
except ItemNotFoundError as err:
|
||||
log.error('Failure to reset: unknown problem "{0}"'.format(err))
|
||||
msg += '<font color="red">Failed to create a background task for resetting "{0}": problem not found.</font>'.format(problem_url)
|
||||
except Exception as e:
|
||||
log.error("Encountered exception from reset: {0}".format(e))
|
||||
msg += '<font color="red">Failed to create a background task for resetting "{0}": {1}.</font>'.format(problem_url, e.message)
|
||||
except Exception as err:
|
||||
log.error("Encountered exception from reset: {0}".format(err))
|
||||
msg += '<font color="red">Failed to create a background task for resetting "{0}": {1}.</font>'.format(problem_url, err.message)
|
||||
|
||||
elif "Show Background Task History for Student" in action:
|
||||
# put this before the non-student case, since the use of "in" will cause this to be missed
|
||||
@@ -462,10 +474,10 @@ def instructor_dashboard(request, course_id):
|
||||
return return_csv('grades %s.csv' % aname, datatable)
|
||||
|
||||
elif 'remote gradebook' in action:
|
||||
fp = StringIO()
|
||||
return_csv('', datatable, fp=fp)
|
||||
fp.seek(0)
|
||||
files = {'datafile': fp}
|
||||
file_pointer = StringIO()
|
||||
return_csv('', datatable, file_pointer=file_pointer)
|
||||
file_pointer.seek(0)
|
||||
files = {'datafile': file_pointer}
|
||||
msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files)
|
||||
msg += msg2
|
||||
|
||||
@@ -687,6 +699,34 @@ def instructor_dashboard(request, course_id):
|
||||
ret = _do_enroll_students(course, course_id, students, overload=overload)
|
||||
datatable = ret['datatable']
|
||||
|
||||
#----------------------------------------
|
||||
# email
|
||||
|
||||
elif action == 'Send email':
|
||||
email_to_option = request.POST.get("to_option")
|
||||
email_subject = request.POST.get("subject")
|
||||
html_message = request.POST.get("message")
|
||||
text_message = html_to_text(html_message)
|
||||
|
||||
email = CourseEmail(course_id=course_id,
|
||||
sender=request.user,
|
||||
to_option=email_to_option,
|
||||
subject=email_subject,
|
||||
html_message=html_message,
|
||||
text_message=text_message)
|
||||
|
||||
email.save()
|
||||
|
||||
tasks.delegate_email_batches.delay(
|
||||
email.id,
|
||||
request.user.id
|
||||
)
|
||||
|
||||
if email_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>'
|
||||
else:
|
||||
email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending.</p></div>'
|
||||
|
||||
#----------------------------------------
|
||||
# psychometrics
|
||||
|
||||
@@ -752,6 +792,19 @@ def instructor_dashboard(request, course_id):
|
||||
else:
|
||||
instructor_tasks = None
|
||||
|
||||
# HTML editor for email
|
||||
if idash_mode == 'Email':
|
||||
html_module = HtmlDescriptor(course.system, {'data': html_message})
|
||||
email_editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')()
|
||||
else:
|
||||
email_editor = None
|
||||
|
||||
# Flag for whether or not we display the email tab (depending upon
|
||||
# what backing store this course using (Mongo vs. XML))
|
||||
if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \
|
||||
modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE:
|
||||
show_email_tab = True
|
||||
|
||||
# display course stats only if there is no other table to display:
|
||||
course_stats = None
|
||||
if not datatable:
|
||||
@@ -768,6 +821,13 @@ def instructor_dashboard(request, course_id):
|
||||
'course_stats': course_stats,
|
||||
'msg': msg,
|
||||
'modeflag': {idash_mode: 'selectedmode'},
|
||||
|
||||
'to_option': email_to_option, # email
|
||||
'subject': email_subject, # email
|
||||
'editor': email_editor, # email
|
||||
'email_msg': email_msg, # email
|
||||
'show_email_tab': show_email_tab, # email
|
||||
|
||||
'problems': problems, # psychometrics
|
||||
'plots': plots, # psychometrics
|
||||
'course_errors': modulestore().get_item_errors(course.location),
|
||||
|
||||
@@ -102,6 +102,11 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
|
||||
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME)
|
||||
EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND)
|
||||
EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None)
|
||||
EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost
|
||||
EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25
|
||||
EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False
|
||||
EMAILS_PER_TASK = ENV_TOKENS.get('EMAILS_PER_TASK', 100)
|
||||
EMAILS_PER_QUERY = ENV_TOKENS.get('EMAILS_PER_QUERY', 1000)
|
||||
SITE_NAME = ENV_TOKENS['SITE_NAME']
|
||||
SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE)
|
||||
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
@@ -122,6 +127,7 @@ CACHES = ENV_TOKENS['CACHES']
|
||||
#Email overrides
|
||||
DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL)
|
||||
DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL)
|
||||
DEFAULT_BULK_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_BULK_FROM_EMAIL', DEFAULT_BULK_FROM_EMAIL)
|
||||
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
|
||||
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
|
||||
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
|
||||
@@ -197,7 +203,7 @@ SECRET_KEY = AUTH_TOKENS['SECRET_KEY']
|
||||
|
||||
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
|
||||
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
|
||||
AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME','edxuploads')
|
||||
AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads')
|
||||
|
||||
DATABASES = AUTH_TOKENS['DATABASES']
|
||||
|
||||
@@ -211,6 +217,9 @@ CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE)
|
||||
OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE',
|
||||
OPEN_ENDED_GRADING_INTERFACE)
|
||||
|
||||
EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is ''
|
||||
EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is ''
|
||||
|
||||
PEARSON_TEST_USER = "pearsontest"
|
||||
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ MITX_FEATURES = {
|
||||
# analytics experiments
|
||||
'ENABLE_INSTRUCTOR_ANALYTICS': False,
|
||||
|
||||
'ENABLE_INSTRUCTOR_EMAIL': False,
|
||||
|
||||
# enable analytics server.
|
||||
# WARNING: THIS SHOULD ALWAYS BE SET TO FALSE UNDER NORMAL
|
||||
# LMS OPERATION. See analytics.py for details about what
|
||||
@@ -289,11 +291,11 @@ WIKI_ENABLED = False
|
||||
|
||||
COURSE_DEFAULT = '6.002x_Fall_2012'
|
||||
COURSE_SETTINGS = {'6.002x_Fall_2012': {'number': '6.002x',
|
||||
'title': 'Circuits and Electronics',
|
||||
'xmlpath': '6002x/',
|
||||
'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012',
|
||||
}
|
||||
}
|
||||
'title': 'Circuits and Electronics',
|
||||
'xmlpath': '6002x/',
|
||||
'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012',
|
||||
}
|
||||
}
|
||||
|
||||
# IP addresses that are allowed to reload the course, etc.
|
||||
# TODO (vshnayder): Will probably need to change as we get real access control in.
|
||||
@@ -361,6 +363,9 @@ IGNORABLE_404_ENDS = ('favicon.ico')
|
||||
# Email
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
DEFAULT_FROM_EMAIL = 'registration@edx.org'
|
||||
DEFAULT_BULK_FROM_EMAIL = 'course-updates@edx.org'
|
||||
EMAILS_PER_TASK = 100
|
||||
EMAILS_PER_QUERY = 1000
|
||||
DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org'
|
||||
SERVER_EMAIL = 'devops@edx.org'
|
||||
TECH_SUPPORT_EMAIL = 'technical@edx.org'
|
||||
@@ -538,17 +543,17 @@ courseware_js = (
|
||||
# 'js/vendor/RequireJS.js' - Require JS wrapper.
|
||||
# See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system
|
||||
main_vendor_js = [
|
||||
'js/vendor/RequireJS.js',
|
||||
'js/vendor/json2.js',
|
||||
'js/vendor/jquery.min.js',
|
||||
'js/vendor/jquery-ui.min.js',
|
||||
'js/vendor/jquery.cookie.js',
|
||||
'js/vendor/jquery.qtip.min.js',
|
||||
'js/vendor/swfobject/swfobject.js',
|
||||
'js/vendor/jquery.ba-bbq.min.js',
|
||||
'js/vendor/annotator.min.js',
|
||||
'js/vendor/annotator.store.min.js',
|
||||
'js/vendor/annotator.tags.min.js'
|
||||
'js/vendor/RequireJS.js',
|
||||
'js/vendor/json2.js',
|
||||
'js/vendor/jquery.min.js',
|
||||
'js/vendor/jquery-ui.min.js',
|
||||
'js/vendor/jquery.cookie.js',
|
||||
'js/vendor/jquery.qtip.min.js',
|
||||
'js/vendor/swfobject/swfobject.js',
|
||||
'js/vendor/jquery.ba-bbq.min.js',
|
||||
'js/vendor/annotator.min.js',
|
||||
'js/vendor/annotator.store.min.js',
|
||||
'js/vendor/annotator.tags.min.js'
|
||||
]
|
||||
|
||||
discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.js'))
|
||||
@@ -612,6 +617,11 @@ PIPELINE_JS = {
|
||||
'output_filename': 'js/lms-main_vendor.js',
|
||||
'test_order': 0,
|
||||
},
|
||||
'module-descriptor-js': {
|
||||
'source_filenames': rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js'),
|
||||
'output_filename': 'js/lms-module-descriptors.js',
|
||||
'test_order': 8,
|
||||
},
|
||||
'module-js': {
|
||||
'source_filenames': rooted_glob(COMMON_ROOT / 'static', 'xmodule/modules/js/*.js'),
|
||||
'output_filename': 'js/lms-modules.js',
|
||||
@@ -756,6 +766,7 @@ INSTALLED_APPS = (
|
||||
'psychometrics',
|
||||
'licenses',
|
||||
'course_groups',
|
||||
'bulk_email',
|
||||
|
||||
# External auth (OpenID, shib)
|
||||
'external_auth',
|
||||
@@ -813,6 +824,7 @@ MKTG_URL_LINK_MAP = {
|
||||
'PRIVACY': 'privacy_edx',
|
||||
}
|
||||
|
||||
|
||||
############################### THEME ################################
|
||||
def enable_theme(theme_name):
|
||||
"""
|
||||
|
||||
@@ -28,6 +28,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
|
||||
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
|
||||
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
|
||||
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
|
||||
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
|
||||
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
|
||||
MITX_FEATURES['ENABLE_SHOPPING_CART'] = True
|
||||
|
||||
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 |
BIN
lms/static/img/problem-editor-icons.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
@@ -41,6 +41,15 @@ $green: rgb(37, 184, 90);
|
||||
$light-gray: #ddd;
|
||||
$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;
|
||||
$m-gray-l1: #97999B;
|
||||
@@ -197,4 +206,4 @@ $homepage-bg-image: '../images/homepage-bg.jpg';
|
||||
$login-banner-image: url(../images/bg-banner-login.png);
|
||||
$register-banner-image: url(../images/bg-banner-register.png);
|
||||
|
||||
$video-thumb-url: '../images/courses/video-thumb.jpg';
|
||||
$video-thumb-url: '../images/courses/video-thumb.jpg';
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
// instructor
|
||||
@import "course/instructor/instructor";
|
||||
@import "course/instructor/instructor_2";
|
||||
@import "course/instructor/email";
|
||||
@import "xmodule/descriptors/css/module-styles.scss";
|
||||
|
||||
// discussion
|
||||
@import "course/discussion/form-wmd-toolbar";
|
||||
|
||||
28
lms/static/sass/course/instructor/_email.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
.email-editor {
|
||||
border: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
.xmodule_edit {
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 10px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
line-height: (16*1.48) + px;
|
||||
line-height: 1.48rem;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-email-action {
|
||||
margin-top: 10px;
|
||||
line-height: 1.3;
|
||||
|
||||
ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,56 @@
|
||||
@extend .top-header;
|
||||
}
|
||||
}
|
||||
|
||||
// form fields
|
||||
.list-fields {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// system feedback - messages
|
||||
.msg {
|
||||
border-radius: 1px;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.copy {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-confirm {
|
||||
border-top: 2px solid green;
|
||||
background: tint(green,90%);
|
||||
|
||||
.copy {
|
||||
color: green;
|
||||
}
|
||||
}
|
||||
|
||||
.list-advice {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 20px 0;
|
||||
|
||||
.item {
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -570,5 +570,10 @@
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
a.email-settings {
|
||||
@extend a.unenroll;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.min.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-world-mill-en.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/course_groups/cohorts.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/codemirror-compressed.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/tiny_mce/tiny_mce.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/tiny_mce/jquery.tinymce.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/htmlmixed.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
|
||||
<%static:js group='module-descriptor-js'/>
|
||||
%if instructor_tasks is not None:
|
||||
<script type="text/javascript" src="${static.url('js/pending_tasks.js')}"></script>
|
||||
%endif
|
||||
@@ -118,6 +124,9 @@ function goto( mode)
|
||||
<a href="#" onclick="goto('Enrollment');" class="${modeflag.get('Enrollment')}">${_("Enrollment")}</a> |
|
||||
<a href="#" onclick="goto('Data');" class="${modeflag.get('Data')}">${_("DataDump")}</a> |
|
||||
<a href="#" onclick="goto('Manage Groups');" class="${modeflag.get('Manage Groups')}">${_("Manage Groups")}</a>
|
||||
%if show_email_tab:
|
||||
| <a href="#" onclick="goto('Email')" class="${modeflag.get('Email')}">Email</a>
|
||||
%endif
|
||||
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'):
|
||||
| <a href="#" onclick="goto('Analytics');" class="${modeflag.get('Analytics')}">${_("Analytics")}</a>
|
||||
%endif
|
||||
@@ -431,6 +440,66 @@ function goto( mode)
|
||||
%endif
|
||||
%endif
|
||||
|
||||
##-----------------------------------------------------------------------------
|
||||
|
||||
%if modeflag.get('Email'):
|
||||
%if email_msg:
|
||||
<p></p><p>${email_msg}</p>
|
||||
%endif
|
||||
|
||||
<ul class="list-fields">
|
||||
<li class="field">
|
||||
<label for="id_to">${_("Send to:")}</label>
|
||||
<select id="id_to" name="to_option">
|
||||
<option value="myself">${_("Myself")}</option>
|
||||
%if to_option == "staff":
|
||||
<option value="staff" selected="selected">${_("Staff and instructors")}</option>
|
||||
%else:
|
||||
<option value="staff">${_("Staff and instructors")}</option>
|
||||
%endif
|
||||
%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>
|
||||
%endif
|
||||
</select>
|
||||
</li>
|
||||
|
||||
<li class="field">
|
||||
<label for="id_subject">${_("Subject: ")}</label>
|
||||
%if subject:
|
||||
<input type="text" id="id_subject" name="subject" maxlength="100" size="75" value="${subject}">
|
||||
%else:
|
||||
<input type="text" id="id_subject" name="subject" maxlength="100" size="75">
|
||||
%endif
|
||||
</li>
|
||||
|
||||
<li class="field">
|
||||
<label>Message:</label>
|
||||
<div class="email-editor">
|
||||
${editor}
|
||||
</div>
|
||||
<input type="hidden" name="message" value="">
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="submit-email-action">
|
||||
${_("Please try not to email students more than once a day. Important things to consider before sending:")}
|
||||
<ul class="list-advice">
|
||||
<li class="item">${_("Have you read over the email to make sure it says everything you want to say?")}</li>
|
||||
<li class="item">${_("Have you sent the email to yourself first to make sure you're happy with how it's displayed?")}</li>
|
||||
</ul>
|
||||
<input type="submit" name="action" value="Send email">
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var emailEditor = XModule.loadModule($('.xmodule_edit'));
|
||||
document.idashform.onsubmit = function() {
|
||||
this.message.value = emailEditor.save()['data'];
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
%endif
|
||||
|
||||
</form>
|
||||
##-----------------------------------------------------------------------------
|
||||
|
||||
@@ -464,7 +533,7 @@ function goto( mode)
|
||||
|
||||
%if analytics_results.get("StudentsDropoffPerDay"):
|
||||
<p>
|
||||
${_("Student activity day by day")}
|
||||
${_("Student activity day by day")}
|
||||
(${analytics_results["StudentsDropoffPerDay"]['time']})
|
||||
</p>
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
from courseware.courses import course_image_url, get_course_about_section
|
||||
from courseware.access import has_access
|
||||
from certificates.models import CertificateStatuses
|
||||
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
|
||||
from xmodule.modulestore.django import modulestore
|
||||
%>
|
||||
<%inherit file="main.html" />
|
||||
|
||||
@@ -16,6 +18,14 @@
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
|
||||
$(".email-settings").click(function(event) {
|
||||
$("#email_settings_course_id").val( $(event.target).data("course-id") );
|
||||
$("#email_settings_course_number").text( $(event.target).data("course-number") );
|
||||
if($(event.target).data("optout") == "False") {
|
||||
$("#receive_emails").prop('checked', true);
|
||||
}
|
||||
});
|
||||
|
||||
$(".unenroll").click(function(event) {
|
||||
$("#unenroll_course_id").val( $(event.target).data("course-id") );
|
||||
$("#unenroll_course_number").text( $(event.target).data("course-number") );
|
||||
@@ -79,6 +89,24 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
$("#email_settings_form").submit(function(){
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: '${reverse("change_email_settings")}',
|
||||
data: $(this).serializeArray(),
|
||||
success: function(data) {
|
||||
if(data.success) {
|
||||
location.href = "${reverse('dashboard')}";
|
||||
}
|
||||
},
|
||||
error: function(xhr, textStatus, error) {
|
||||
if (xhr.status == 403) {
|
||||
location.href = "${reverse('signin_user')}";
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
})(this)
|
||||
</script>
|
||||
</%block>
|
||||
@@ -280,6 +308,10 @@
|
||||
% endif
|
||||
% endif
|
||||
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}">${_('Unregister')}</a>
|
||||
% if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and modulestore().get_modulestore_type(course.id) == MONGO_MODULESTORE_TYPE:
|
||||
<!-- Only show the Email Settings link/modal if this course has bulk email feature enabled -->
|
||||
<a href="#email-settings-modal" class="email-settings" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" data-optout="${course.id in course_optouts}">${_('Email Settings')}</a>
|
||||
% endif
|
||||
</section>
|
||||
</article>
|
||||
|
||||
@@ -313,6 +345,29 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="email-settings-modal" class="modal">
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
<h2>${_('Email Settings for {course_number}').format(course_number='<span id="email_settings_course_number"></span>')}</h2>
|
||||
<hr/>
|
||||
</header>
|
||||
|
||||
<form id="email_settings_form" method="post">
|
||||
<input name="course_id" id="email_settings_course_id" type="hidden" />
|
||||
<label>${_("Receive course emails")} <input type="checkbox" id="receive_emails" name="receive_emails" /></label>
|
||||
<div class="submit">
|
||||
<input type="submit" id="submit" value="Save Settings" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="close-modal">
|
||||
<div class="inner">
|
||||
<p>✕</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="unenroll-modal" class="modal unenroll-modal">
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
|
||||
13
lms/templates/widgets/html-edit.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<section class="html-editor editor">
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="visual-tab tab current" data-tab="visual">${_("Visual")}</a></li>
|
||||
<li><a href="#" class="html-tab tab" data-tab="advanced">${_("HTML")}</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<textarea class="tiny-mce">${data | h}</textarea>
|
||||
<textarea name="" class="edit-box">${data | h}</textarea>
|
||||
</div>
|
||||
</section>
|
||||
@@ -190,6 +190,7 @@ if settings.COURSEWARE_ENABLED:
|
||||
url(r'^courses/?$', 'branding.views.courses', name="courses"),
|
||||
url(r'^change_enrollment$',
|
||||
'student.views.change_enrollment', name="change_enrollment"),
|
||||
url(r'^change_email_settings$', 'student.views.change_email_settings', name="change_email_settings"),
|
||||
|
||||
#About the course
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/about$',
|
||||
|
||||
@@ -10,3 +10,4 @@ graphviz
|
||||
mysql
|
||||
geos
|
||||
mongodb
|
||||
lynx
|
||||
|
||||
@@ -33,3 +33,4 @@ coffeescript
|
||||
mysql-client
|
||||
virtualenvwrapper
|
||||
libgeos-ruby1.8
|
||||
lynx-cur
|
||||
|
||||