From 06b085f8b496a42db1d1c0ff012869408102241c Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq <ahsan.haq@arbisoft.com> Date: Thu, 9 Jul 2015 14:32:28 +0500 Subject: [PATCH] credit eligibility and payment receipt email ECOM-1796 ECOM-1525 --- lms/envs/aws.py | 4 + lms/envs/common.py | 7 + .../credit_eligibility_email.html | 80 +++++++++ .../credit_eligibility_email.txt | 16 ++ .../credit_notification.css | 38 ++++ .../credit_notifications/edx-logo-header.png | Bin 0 -> 1894 bytes .../core/djangoapps/credit/api/eligibility.py | 8 +- openedx/core/djangoapps/credit/email_utils.py | 150 ++++++++++++++++ ...eligibility_email_message__add_field_cr.py | 165 ++++++++++++++++++ openedx/core/djangoapps/credit/models.py | 24 ++- .../core/djangoapps/credit/tests/test_api.py | 27 ++- .../djangoapps/credit/tests/test_signals.py | 3 + requirements/edx/base.txt | 3 + 13 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 lms/templates/credit_notifications/credit_eligibility_email.html create mode 100644 lms/templates/credit_notifications/credit_eligibility_email.txt create mode 100644 lms/templates/credit_notifications/credit_notification.css create mode 100644 lms/templates/credit_notifications/edx-logo-header.png create mode 100644 openedx/core/djangoapps/credit/email_utils.py create mode 100644 openedx/core/djangoapps/credit/migrations/0016_auto__add_field_creditprovider_eligibility_email_message__add_field_cr.py diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 4952abed45b..bf0deab099f 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -331,6 +331,10 @@ FOOTER_ORGANIZATION_IMAGE = ENV_TOKENS.get('FOOTER_ORGANIZATION_IMAGE', FOOTER_O FOOTER_CACHE_TIMEOUT = ENV_TOKENS.get('FOOTER_CACHE_TIMEOUT', FOOTER_CACHE_TIMEOUT) FOOTER_BROWSER_CACHE_MAX_AGE = ENV_TOKENS.get('FOOTER_BROWSER_CACHE_MAX_AGE', FOOTER_BROWSER_CACHE_MAX_AGE) +# Credit notifications settings +NOTIFICATION_EMAIL_CSS = ENV_TOKENS.get('NOTIFICATION_EMAIL_CSS', NOTIFICATION_EMAIL_CSS) +NOTIFICATION_EMAIL_EDX_LOGO = ENV_TOKENS.get('NOTIFICATION_EMAIL_EDX_LOGO', NOTIFICATION_EMAIL_EDX_LOGO) + ############# CORS headers for cross-domain requests ################# if FEATURES.get('ENABLE_CORS_HEADERS') or FEATURES.get('ENABLE_CROSS_DOMAIN_CSRF_COOKIE'): diff --git a/lms/envs/common.py b/lms/envs/common.py index 0189b7dbc75..36b6fbcfe90 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1097,6 +1097,9 @@ FOOTER_CACHE_TIMEOUT = 30 * 60 # Max age cache control header for the footer (controls browser caching). FOOTER_BROWSER_CACHE_MAX_AGE = 5 * 60 +# Credit api notification cache timeout +CREDIT_NOTIFICATION_CACHE_TIMEOUT = 5 * 60 * 60 + ################################# Deprecation warnings ##################### # Ignore deprecation warnings (so we don't clutter Jenkins builds/production) @@ -2572,3 +2575,7 @@ LTI_USER_EMAIL_DOMAIN = 'lti.example.com' # Number of seconds before JWT tokens expire JWT_EXPIRATION = 30 JWT_ISSUER = None + +# Credit notifications settings +NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css" +NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png" diff --git a/lms/templates/credit_notifications/credit_eligibility_email.html b/lms/templates/credit_notifications/credit_eligibility_email.html new file mode 100644 index 00000000000..3d5ee38bbb1 --- /dev/null +++ b/lms/templates/credit_notifications/credit_eligibility_email.html @@ -0,0 +1,80 @@ +<%! from django.utils.translation import ugettext as _ %> + +<!DOCTYPE html> +<html> +<head lang="en"> + <meta charset="UTF-8"> + <title></title> +</head> +<body> + +<table class="cn-container"> + <tbody> + <tr> + <td class="cn-body"> + <table> + <tr> + <td class="cn-img-wrapper"> + <a target="_blank" title="" href="#"> + <img class="cn-img" src="cid:${branded_logo}"> + </a> + </td> + </tr> + + <tr><td class="cn-content-clear"></td></tr> + + <tr> + <td class="cn-content"> + <p> + ${_("Hi {name},").format(name=full_name)} + </p> + + <p> + ${_("Congratulations! You are eligible to receive university credit from edX partners! Click {link} to get your credit now.").format( + link=u'<a href="{dashboard_url}">here</a>'.format( + dashboard_url=dashboard_link + )) + } + </p> + + <p> + ${_("Credit from can help you get a jump start on your university degree, finish a degree already started, or fulfill requirements at a different academic institution.")} + </p> + + <p> + ${_('To get university credit for {course_name}, simply go to your {link} and click the yellow "Get Credit" button. No application, transcript, or grade report is required.').format( + course_name=course_name, + link=u'<a href="{dashboard_url}">edX dashboard</a>'.format( + dashboard_url=dashboard_link + ) + )} + </p> + + <p> + ${_("We hope you enjoyed the course, and we hope to see you in future edX courses!")}<br/> + ${_("The edX team")} + </p> + </td> + </tr> + + <tr><td class="cn-content-clear cn-footer"></td></tr> + + <tr> + <td class="cn-footer-content"> + <p> + <a href="${credit_course_link}"> ${_("Find more edX courses you can take for university credit.")} </a> + </p> + </td> + </tr> + </table> + </td> + </tr> + + + </tbody> +</table> +<img src="${tracking_pixel}"/> + + +</body> +</html> diff --git a/lms/templates/credit_notifications/credit_eligibility_email.txt b/lms/templates/credit_notifications/credit_eligibility_email.txt new file mode 100644 index 00000000000..2dde4b702f3 --- /dev/null +++ b/lms/templates/credit_notifications/credit_eligibility_email.txt @@ -0,0 +1,16 @@ +<%! from django.utils.translation import ugettext as _ %> +${_("Hi {name},").format(name=full_name)} + +${_("Congratulations! You are eligible to receive university credit from edX and our partners!")} + +${_("Click on the link below to get your credit now")} + +${dashboard_link} + +${_("Credit from can help you get a jump start on your university degree, finish a degree already started, or fulfill requirements at a different academic institution.")} + +${_('To get university credit for {course_name}, simply go to your edX dashboard and click the yellow "Get Credit" button. No application, transcript, or grade report is required.').format(course_name=course_name)} + +${_("We hope you enjoyed the course, and we hope to see you in future edX courses!")} + +${_("The edX team")} diff --git a/lms/templates/credit_notifications/credit_notification.css b/lms/templates/credit_notifications/credit_notification.css new file mode 100644 index 00000000000..ae80551d6ca --- /dev/null +++ b/lms/templates/credit_notifications/credit_notification.css @@ -0,0 +1,38 @@ +.cn-container { + font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; + width: 600px; + font-size: 14px; + line-height: 150%; + border: 2px solid #eeeeee; + margin: 0 auto; +} + +.cn-container .cn-body { + padding: 20px; +} + +.cn-img-wrapper { + padding: 9px 0; +} + +.cn-img-wrapper .cn-img { + float: left; + width: 80px; +} + +.cn-content-clear { + padding: 0px 18px 18px; + border-top: 3px solid #1d9fd9; +} + +.cn-content-clear .cn-footer { + border-top-width: 6px; +} + +.cn-content p { + padding: 5px 0; +} + +.cn-footer-content p { + padding: 5px 0; +} diff --git a/lms/templates/credit_notifications/edx-logo-header.png b/lms/templates/credit_notifications/edx-logo-header.png new file mode 100644 index 0000000000000000000000000000000000000000..486771df260fdc64862dedf186d4816b30fd96a7 GIT binary patch literal 1894 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 literal 0 HcmV?d00001 diff --git a/openedx/core/djangoapps/credit/api/eligibility.py b/openedx/core/djangoapps/credit/api/eligibility.py index 551b978a5b2..91aa3f79e4c 100644 --- a/openedx/core/djangoapps/credit/api/eligibility.py +++ b/openedx/core/djangoapps/credit/api/eligibility.py @@ -6,6 +6,7 @@ whether a user has satisfied those requirements. import logging from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse +from openedx.core.djangoapps.credit.email_utils import send_credit_notifications from openedx.core.djangoapps.credit.models import ( CreditCourse, CreditRequirement, @@ -275,7 +276,12 @@ def set_credit_requirement_status(username, course_key, req_namespace, req_name, # If we're marking this requirement as "satisfied", there's a chance # that the user has met all eligibility requirements. if status == "satisfied": - CreditEligibility.update_eligibility(reqs, username, course_key) + is_eligible, eligibility_record_created = CreditEligibility.update_eligibility(reqs, username, course_key) + if eligibility_record_created and is_eligible: + try: + send_credit_notifications(username, course_key) + except Exception: # pylint: disable=broad-except + log.error("Error sending email") def get_credit_requirement_status(course_key, username, namespace=None, name=None): diff --git a/openedx/core/djangoapps/credit/email_utils.py b/openedx/core/djangoapps/credit/email_utils.py new file mode 100644 index 00000000000..d3dd58ff384 --- /dev/null +++ b/openedx/core/djangoapps/credit/email_utils.py @@ -0,0 +1,150 @@ +""" +This file contains utility functions which will responsible for sending emails. +""" + +import os + +import logging +import pynliner +import urlparse +import uuid + +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.staticfiles import finders +from django.core.cache import cache +from django.core.mail import EmailMessage +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ + +from email.mime.image import MIMEImage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from eventtracking import tracker + +from edxmako.shortcuts import render_to_string +from microsite_configuration import microsite +from xmodule.modulestore.django import modulestore + + +log = logging.getLogger(__name__) + + +def send_credit_notifications(username, course_key): + """Sends email notification to user on different phases during credit + course e.g., credit eligibility, credit payment etc. + """ + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + log.error('No user with %s exist', username) + return + + course = modulestore().get_course(course_key, depth=0) + course_display_name = course.display_name + branded_logo = dict(title='Logo', path=settings.NOTIFICATION_EMAIL_EDX_LOGO, cid=str(uuid.uuid4())) + tracking_context = tracker.get_tracker().resolve_context() + tracking_id = str(tracking_context.get('user_id')) + client_id = str(tracking_context.get('client_id')) + events = '&t=event&ec=email&ea=open' + tracking_pixel = 'https://www.google-analytics.com/collect?v=1&tid' + tracking_id + '&cid' + client_id + events + dashboard_link = _email_url_parser('dashboard') + credit_course_link = _email_url_parser('courses', "?type=credit") + context = { + 'full_name': user.get_full_name(), + 'platform_name': settings.PLATFORM_NAME, + 'course_name': course_display_name, + 'branded_logo': branded_logo['cid'], + 'dashboard_link': dashboard_link, + 'credit_course_link': credit_course_link, + 'tracking_pixel': tracking_pixel, + } + + # create the root email message + notification_msg = MIMEMultipart('related') + # add 'alternative' part to root email message to encapsulate the plain and + # HTML versions, so message agents can decide which they want to display. + msg_alternative = MIMEMultipart('alternative') + notification_msg.attach(msg_alternative) + # render the credit notification templates + subject = _("Course Credit Eligibility") + + # add alternative plain text message + email_body_plain = render_to_string('credit_notifications/credit_eligibility_email.txt', context) + msg_alternative.attach(MIMEText(email_body_plain, _subtype='plain')) + + # add alternative html message + email_body = cache.get('css-email-body') + if not email_body: + email_body = with_inline_css( + render_to_string("credit_notifications/credit_eligibility_email.html", context) + ) + cache.set('css-email-body', email_body, settings.CREDIT_NOTIFICATION_CACHE_TIMEOUT) + + msg_alternative.attach(MIMEText(email_body, _subtype='html')) + # add images + logo_image = cache.get('attached-logo-email') + if not logo_image: + logo_image = attach_image(branded_logo, 'Header Logo') + if logo_image: + notification_msg.attach(logo_image) + cache.set('attached-logo-email', logo_image, settings.CREDIT_NOTIFICATION_CACHE_TIMEOUT) + + from_address = microsite.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL) + to_address = user.email + + # send the root email message + msg = EmailMessage(subject, None, from_address, [to_address]) + msg.attach(notification_msg) + msg.send() + + +def with_inline_css(html_without_css): + """Returns html with inline css if the css file path exists + else returns html with out the inline css. + """ + css_filepath = settings.NOTIFICATION_EMAIL_CSS + if not css_filepath.startswith('/'): + css_filepath = finders.FileSystemFinder().find(settings.NOTIFICATION_EMAIL_CSS) + + if css_filepath: + with open(css_filepath, "r") as _file: + css_content = _file.read() + + # insert style tag in the html and run pyliner. + html_with_inline_css = pynliner.fromString('<style>' + css_content + '</style>' + html_without_css) + return html_with_inline_css + + return html_without_css + + +def attach_image(img_dict, filename): + """ + Attach images in the email headers. + """ + img_path = img_dict['path'] + if not img_path.startswith('/'): + img_path = finders.FileSystemFinder().find(img_path) + + if img_path: + with open(img_path, 'rb') as img: + msg_image = MIMEImage(img.read(), name=os.path.basename(img_path)) + msg_image.add_header('Content-ID', '<{}>'.format(img_dict['cid'])) + msg_image.add_header("Content-Disposition", "inline", filename=filename) + return msg_image + + +def _email_url_parser(url_name, extra_param=None): + """Parse url according to 'SITE_NAME' which will be used in the mail. + + Args: + url_name(str): Name of the url to be parsed + extra_param(str): Any extra parameters to be added with url if any + + Returns: + str + """ + site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME) + dashboard_url_path = reverse(url_name) + extra_param if extra_param else reverse(url_name) + dashboard_link_parts = ("https", site_name, dashboard_url_path, '', '', '') + return urlparse.urlunparse(dashboard_link_parts) diff --git a/openedx/core/djangoapps/credit/migrations/0016_auto__add_field_creditprovider_eligibility_email_message__add_field_cr.py b/openedx/core/djangoapps/credit/migrations/0016_auto__add_field_creditprovider_eligibility_email_message__add_field_cr.py new file mode 100644 index 00000000000..64417e83981 --- /dev/null +++ b/openedx/core/djangoapps/credit/migrations/0016_auto__add_field_creditprovider_eligibility_email_message__add_field_cr.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'CreditProvider.eligibility_email_message' + db.add_column('credit_creditprovider', 'eligibility_email_message', + self.gf('django.db.models.fields.TextField')(default=''), + keep_default=False) + + # Adding field 'CreditProvider.receipt_email_message' + db.add_column('credit_creditprovider', 'receipt_email_message', + self.gf('django.db.models.fields.TextField')(default=''), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'CreditProvider.eligibility_email_message' + db.delete_column('credit_creditprovider', 'eligibility_email_message') + + # Deleting field 'CreditProvider.receipt_email_message' + db.delete_column('credit_creditprovider', 'receipt_email_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'}) + }, + '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'}) + }, + 'credit.creditcourse': { + 'Meta': {'object_name': 'CreditCourse'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'credit.crediteligibility': { + 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'deadline': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2016, 7, 9, 0, 0)'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.creditprovider': { + 'Meta': {'object_name': 'CreditProvider'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'eligibility_email_message': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'fulfillment_instructions': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider_description': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'provider_status_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}), + 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}), + 'receipt_email_message': ('django.db.models.fields.TextField', [], {'default': "''"}) + }, + 'credit.creditrequest': { + 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}) + }, + 'credit.creditrequirement': { + 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'criteria': ('jsonfield.fields.JSONField', [], {}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'credit.creditrequirementstatus': { + 'Meta': {'unique_together': "(('username', 'requirement'),)", 'object_name': 'CreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequest': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequirementstatus': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + } + } + + complete_apps = ['credit'] \ No newline at end of file diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index a9c936f63ae..11c82d6e132 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -111,6 +111,24 @@ class CreditProvider(TimeStampedModel): ) ) + eligibility_email_message = models.TextField( + default="", + help_text=ugettext_lazy( + "Plain text or html content for displaying custom message inside " + "credit eligibility email content which is sent when user has met " + "all credit eligibility requirements." + ) + ) + + receipt_email_message = models.TextField( + default="", + help_text=ugettext_lazy( + "Plain text or html content for displaying custom message inside " + "credit receipt email content which is sent *after* paying to get " + "credit for a credit course." + ) + ) + CREDIT_PROVIDERS_CACHE_KEY = "credit.providers.list" @classmethod @@ -479,6 +497,7 @@ class CreditEligibility(TimeStampedModel): username (str): Identifier of the user being updated. course_key (CourseKey): Identifier of the course. + Returns: tuple """ # Check all requirements for the course to determine if the user # is eligible. We need to check all the *requirements* @@ -497,8 +516,11 @@ class CreditEligibility(TimeStampedModel): username=username, course=CreditCourse.objects.get(course_key=course_key), ) + return is_eligible, True except IntegrityError: - pass + return is_eligible, False + else: + return is_eligible, False @classmethod def get_user_eligibilities(cls, username): diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 7a2c7f6e8a7..1711d6fad7a 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -9,10 +9,12 @@ import pytz import unittest from django.conf import settings +from django.core import mail from django.test import TestCase from django.test.utils import override_settings from django.db import connection, transaction from django.core.urlresolvers import reverse, NoReverseMatch +from unittest import skipUnless from opaque_keys.edx.keys import CourseKey @@ -34,6 +36,8 @@ from openedx.core.djangoapps.credit.models import ( CreditEligibility ) from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6" @@ -46,7 +50,7 @@ from util.testing import UrlResetMixin "ASU": TEST_CREDIT_PROVIDER_SECRET_KEY, "MIT": TEST_CREDIT_PROVIDER_SECRET_KEY }) -class CreditApiTestBase(TestCase): +class CreditApiTestBase(ModuleStoreTestCase): """ Base class for test cases of the credit API. """ @@ -58,6 +62,14 @@ class CreditApiTestBase(TestCase): PROVIDER_DESCRIPTION = "A new model for the Witchcraft and Wizardry School System." ENABLE_INTEGRATION = True FULFILLMENT_INSTRUCTIONS = "Sample fulfillment instruction for credit completion." + USER_INFO = { + "username": "bob", + "email": "bob@example.com", + "password": "test_bob", + "full_name": "Bob", + "mailing_address": "123 Fake Street, Cambridge MA", + "country": "US", + } def setUp(self, **kwargs): super(CreditApiTestBase, self).setUp() @@ -80,6 +92,7 @@ class CreditApiTestBase(TestCase): return credit_course +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS') @ddt.ddt class CreditRequirementApiTests(CreditApiTestBase): """ @@ -305,6 +318,8 @@ class CreditRequirementApiTests(CreditApiTestBase): def test_satisfy_all_requirements(self): # Configure a course with two credit requirements self.add_credit_course() + CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course') + requirements = [ { "namespace": "grade", @@ -323,10 +338,12 @@ class CreditRequirementApiTests(CreditApiTestBase): ] api.set_credit_requirements(self.course_key, requirements) + user = UserFactory.create(username=self.USER_INFO['username'], password=self.USER_INFO['password']) + # Satisfy one of the requirements, but not the other with self.assertNumQueries(7): api.set_credit_requirement_status( - "bob", + user.username, self.course_key, requirements[0]["namespace"], requirements[0]["name"] @@ -336,7 +353,7 @@ class CreditRequirementApiTests(CreditApiTestBase): self.assertFalse(api.is_user_eligible_for_credit("bob", self.course_key)) # Satisfy the other requirement - with self.assertNumQueries(10): + with self.assertNumQueries(11): api.set_credit_requirement_status( "bob", self.course_key, @@ -347,6 +364,10 @@ class CreditRequirementApiTests(CreditApiTestBase): # Now the user should be eligible self.assertTrue(api.is_user_eligible_for_credit("bob", self.course_key)) + # Credit eligible mail should be sent + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Course Credit Eligibility') + # The user should remain eligible even if the requirement status is later changed api.set_credit_requirement_status( "bob", diff --git a/openedx/core/djangoapps/credit/tests/test_signals.py b/openedx/core/djangoapps/credit/tests/test_signals.py index 8499a9267d2..f6013f6e25a 100644 --- a/openedx/core/djangoapps/credit/tests/test_signals.py +++ b/openedx/core/djangoapps/credit/tests/test_signals.py @@ -6,7 +6,9 @@ import pytz import ddt from datetime import timedelta, datetime +from django.conf import settings from django.test.client import RequestFactory +from unittest import skipUnless from openedx.core.djangoapps.credit.api import ( set_credit_requirements, get_credit_requirement_status @@ -19,6 +21,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS') @ddt.ddt class TestMinGradedRequirementStatus(ModuleStoreTestCase): """Test cases to check the minimum grade requirement status updated. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index efa4f34121f..f3f4239b681 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -156,3 +156,6 @@ analytics-python==0.4.4 # Needed for mailchimp(mailing djangoapp) mailsnake==1.6.2 jsonfield==1.0.3 + +# Inlines CSS styles into HTML for email notifications. +pynliner==0.5.2 -- GitLab