Merge pull request #12059 from edx/patch/2015-04-06

Patch/2015 04 06
This commit is contained in:
Adam
2016-04-07 11:58:00 -04:00
50 changed files with 956 additions and 261 deletions

View File

@@ -90,7 +90,7 @@
"LOCAL_LOGLEVEL": "INFO",
"LOGGING_ENV": "sandbox",
"LOG_DIR": "** OVERRIDDEN **",
"MEDIA_URL": "",
"MEDIA_URL": "/media/",
"MKTG_URL_LINK_MAP": {},
"PLATFORM_NAME": "edX",
"SERVER_EMAIL": "devops@example.com",

View File

@@ -6,6 +6,7 @@ from django.forms import models
from django.contrib import admin
from django.contrib.admin import ListFilter
from django.core.cache import caches, InvalidCacheBackendError
from django.core.files.base import File
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
@@ -178,7 +179,16 @@ class KeyedConfigurationModelAdmin(ConfigurationModelAdmin):
get = request.GET.copy()
source_id = int(get.pop('source')[0])
source = get_object_or_404(self.model, pk=source_id)
get.update(models.model_to_dict(source))
source_dict = models.model_to_dict(source)
for field_name, field_value in source_dict.items():
# read files into request.FILES, if:
# * user hasn't ticked the "clear" checkbox
# * user hasn't uploaded a new file
if field_value and isinstance(field_value, File):
clear_checkbox_name = '{0}-clear'.format(field_name)
if request.POST.get(clear_checkbox_name) != 'on':
request.FILES.setdefault(field_name, field_value)
get[field_name] = field_value
request.GET = get
# Call our grandparent's add_view, skipping the parent code
# because the parent code has a different way to prepopulate new configuration entries

View File

@@ -398,7 +398,7 @@ class CourseMode(models.Model):
@classmethod
def has_verified_mode(cls, course_mode_dict):
"""Check whether the modes for a course allow a student to pursue a verfied certificate.
"""Check whether the modes for a course allow a student to pursue a verified certificate.
Args:
course_mode_dict (dictionary mapping course mode slugs to Modes)

View File

@@ -37,7 +37,8 @@ class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin):
""" Don't show every single field in the admin change list """
return (
'name', 'enabled', 'backend_name', 'entity_id', 'metadata_source',
'has_data', 'icon_class', 'change_date', 'changed_by', 'edit_link'
'has_data', 'icon_class', 'icon_image', 'change_date',
'changed_by', 'edit_link'
)
def has_data(self, inst):
@@ -104,6 +105,7 @@ class LTIProviderConfigAdmin(KeyedConfigurationModelAdmin):
exclude = (
'icon_class',
'icon_image',
'secondary',
)

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('third_party_auth', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='ltiproviderconfig',
name='icon_image',
field=models.FileField(help_text=b'If there is no Font Awesome icon available for this provider, upload a custom image. SVG images are recommended as they can scale to any size.', upload_to=b'', blank=True),
),
migrations.AddField(
model_name='oauth2providerconfig',
name='icon_image',
field=models.FileField(help_text=b'If there is no Font Awesome icon available for this provider, upload a custom image. SVG images are recommended as they can scale to any size.', upload_to=b'', blank=True),
),
migrations.AddField(
model_name='samlproviderconfig',
name='icon_image',
field=models.FileField(help_text=b'If there is no Font Awesome icon available for this provider, upload a custom image. SVG images are recommended as they can scale to any size.', upload_to=b'', blank=True),
),
migrations.AlterField(
model_name='ltiproviderconfig',
name='icon_class',
field=models.CharField(default=b'fa-sign-in', help_text=b'The Font Awesome (or custom) icon class to use on the login button for this provider. Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university', max_length=50, blank=True),
),
migrations.AlterField(
model_name='oauth2providerconfig',
name='icon_class',
field=models.CharField(default=b'fa-sign-in', help_text=b'The Font Awesome (or custom) icon class to use on the login button for this provider. Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university', max_length=50, blank=True),
),
migrations.AlterField(
model_name='samlproviderconfig',
name='icon_class',
field=models.CharField(default=b'fa-sign-in', help_text=b'The Font Awesome (or custom) icon class to use on the login button for this provider. Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university', max_length=50, blank=True),
),
]

View File

@@ -70,12 +70,25 @@ class ProviderConfig(ConfigurationModel):
Abstract Base Class for configuring a third_party_auth provider
"""
icon_class = models.CharField(
max_length=50, default='fa-sign-in',
max_length=50,
blank=True,
default='fa-sign-in',
help_text=(
'The Font Awesome (or custom) icon class to use on the login button for this provider. '
'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university'
),
)
# We use a FileField instead of an ImageField here because ImageField
# doesn't support SVG. This means we don't get any image validation, but
# that should be fine because only trusted users should be uploading these
# anyway.
icon_image = models.FileField(
blank=True,
help_text=(
'If there is no Font Awesome icon available for this provider, upload a custom image. '
'SVG images are recommended as they can scale to any size.'
),
)
name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)")
secondary = models.BooleanField(
default=False,
@@ -109,6 +122,12 @@ class ProviderConfig(ConfigurationModel):
app_label = "third_party_auth"
abstract = True
def clean(self):
""" Ensure that either `icon_class` or `icon_image` is set """
super(ProviderConfig, self).clean()
if bool(self.icon_class) == bool(self.icon_image):
raise ValidationError('Either an icon class or an icon image must be given (but not both)')
@property
def provider_id(self):
""" Unique string key identifying this provider. Must be URL and css class friendly. """
@@ -500,9 +519,15 @@ class LTIProviderConfig(ProviderConfig):
"""
prefix = 'lti'
backend_name = 'lti'
icon_class = None # This provider is not visible to users
secondary = False # This provider is not visible to users
accepts_logins = False # LTI login cannot be initiated by the tool provider
# This provider is not visible to users
icon_class = None
icon_image = None
secondary = False
# LTI login cannot be initiated by the tool provider
accepts_logins = False
KEY_FIELDS = ('lti_consumer_key', )
lti_consumer_key = models.CharField(

View File

@@ -0,0 +1,46 @@
"""Integration tests for Azure Active Directory / Microsoft Account provider."""
from third_party_auth.tests.specs import base
# pylint: disable=test-inherits-tests
class AzureADOauth2IntegrationTest(base.Oauth2IntegrationTest):
"""Integration tests for Azure Active Directory / Microsoft Account provider."""
def setUp(self):
super(AzureADOauth2IntegrationTest, self).setUp()
self.provider = self.configure_azure_ad_provider(
enabled=True,
key='azure_ad_oauth2_key',
secret='azure_ad_oauth2_secret',
)
TOKEN_RESPONSE_DATA = {
'exp': 1234590302,
'nbf': 1234586402,
'iat': 1234586402,
'expires_on': '1234590302',
'ver': '1.0',
'access_token': 'access_token_value',
'expires_in': '3599',
'id_token': 'id_token_value',
'token_type': 'Bearer',
'refresh_token': 'REFRESH1234567890',
'iss': 'https://sts.windows.net/abcdefgh-1234-5678-900a-0aa0a00aa0aa/',
'ipaddr': '123.123.123.123',
}
USER_RESPONSE_DATA = {
'oid': 'abcdefgh-1234-5678-900a-0aa0a00aa0aa',
'aud': 'abcdefgh-1234-5678-900a-0aa0a00aa0aa',
'tid': 'abcdefgh-1234-5678-900a-0aa0a00aa0aa',
'amr': ['pwd'],
'unique_name': 'email_value@example.com',
'upn': 'email_value@example.com',
'family_name': 'family_name_value',
'name': 'name_value',
'given_name': 'given_name_value',
'sub': 'aBC_ab12345678h94CSgP1lTYJCHATGQDAcfg8jSOck',
}
def get_username(self):
return self.get_response_data().get('name')

View File

@@ -0,0 +1,85 @@
"""
Tests third_party_auth admin views
"""
import unittest
from django.conf import settings
from django.contrib.admin.sites import AdminSite
from django.core.urlresolvers import reverse
from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import models
from student.tests.factories import UserFactory
from third_party_auth.admin import OAuth2ProviderConfigAdmin
from third_party_auth.models import OAuth2ProviderConfig
from third_party_auth.tests import testutil
# This is necessary because cms does not implement third party auth
@unittest.skipUnless(settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'), 'third party auth not enabled')
class Oauth2ProviderConfigAdminTest(testutil.TestCase):
"""
Tests for oauth2 provider config admin
"""
def test_oauth2_provider_edit_icon_image(self):
"""
Test that we can update an OAuth provider's icon image from the admin
form.
OAuth providers are updated using KeyedConfigurationModelAdmin, which
updates models by adding a new instance that replaces the old one,
instead of editing the old instance directly.
Updating the icon image is tricky here because
KeyedConfigurationModelAdmin copies data over from the previous
version by injecting its attributes into request.GET, but the icon
ends up in request.FILES. We need to ensure that the value is
prepopulated correctly, and that we can clear and update the image.
"""
# Login as a super user
user = UserFactory.create(is_staff=True, is_superuser=True)
user.save()
self.client.login(username=user.username, password='test')
# Get baseline provider count
providers = OAuth2ProviderConfig.objects.all()
pcount = len(providers)
# Create a provider
provider1 = self.configure_dummy_provider(
enabled=True,
icon_class='',
icon_image=SimpleUploadedFile('icon.svg', '<svg><rect width="50" height="100"/></svg>'),
)
# Get the provider instance with active flag
providers = OAuth2ProviderConfig.objects.all()
self.assertEquals(len(providers), 1)
self.assertEquals(providers[pcount].id, provider1.id)
# Edit the provider via the admin edit link
admin = OAuth2ProviderConfigAdmin(provider1, AdminSite())
# pylint: disable=protected-access
update_url = reverse('admin:{}_{}_add'.format(admin.model._meta.app_label, admin.model._meta.model_name))
update_url += "?source={}".format(provider1.pk)
# Remove the icon_image from the POST data, to simulate unchanged icon_image
post_data = models.model_to_dict(provider1)
del post_data['icon_image']
# Change the name, to verify POST
post_data['name'] = 'Another name'
# Post the edit form: expecting redirect
response = self.client.post(update_url, post_data)
self.assertEquals(response.status_code, 302)
# Editing the existing provider creates a new provider instance
providers = OAuth2ProviderConfig.objects.all()
self.assertEquals(len(providers), pcount + 2)
self.assertEquals(providers[pcount].id, provider1.id)
provider2 = providers[pcount + 1]
# Ensure the icon_image was preserved on the new provider instance
self.assertEquals(provider2.icon_image, provider1.icon_image)
self.assertEquals(provider2.name, post_data['name'])

View File

@@ -13,6 +13,7 @@ import django.test
from mako.template import Template
import mock
import os.path
from storages.backends.overwrite import OverwriteStorage
from third_party_auth.models import (
OAuth2ProviderConfig,
@@ -52,6 +53,17 @@ class FakeDjangoSettings(object):
class ThirdPartyAuthTestMixin(object):
""" Helper methods useful for testing third party auth functionality """
def setUp(self, *args, **kwargs):
# Django's FileSystemStorage will rename files if they already exist.
# This storage backend overwrites files instead, which makes it easier
# to make assertions about filenames.
icon_image_field = OAuth2ProviderConfig._meta.get_field('icon_image') # pylint: disable=protected-access
patch = mock.patch.object(icon_image_field, 'storage', OverwriteStorage())
patch.start()
self.addCleanup(patch.stop)
super(ThirdPartyAuthTestMixin, self).setUp(*args, **kwargs)
def tearDown(self):
config_cache.clear()
super(ThirdPartyAuthTestMixin, self).tearDown()
@@ -112,6 +124,16 @@ class ThirdPartyAuthTestMixin(object):
kwargs.setdefault("secret", "test")
return cls.configure_oauth_provider(**kwargs)
@classmethod
def configure_azure_ad_provider(cls, **kwargs):
""" Update the settings for the Azure AD third party auth provider/backend """
kwargs.setdefault("name", "Azure AD")
kwargs.setdefault("backend_name", "azuread-oauth2")
kwargs.setdefault("icon_class", "fa-azuread")
kwargs.setdefault("key", "test")
kwargs.setdefault("secret", "test")
return cls.configure_oauth_provider(**kwargs)
@classmethod
def configure_twitter_provider(cls, **kwargs):
""" Update the settings for the Twitter third party auth provider/backend """
@@ -124,7 +146,7 @@ class ThirdPartyAuthTestMixin(object):
@classmethod
def configure_dummy_provider(cls, **kwargs):
""" Update the settings for the Twitter third party auth provider/backend """
""" Update the settings for the Dummy third party auth provider/backend """
kwargs.setdefault("name", "Dummy")
kwargs.setdefault("backend_name", "dummy")
return cls.configure_oauth_provider(**kwargs)

View File

@@ -24,6 +24,7 @@
// * +Content - Text Wrap - Extend
// * +Content - Text Truncate - Extend
// * +Icon - Font-Awesome - Extend
// * +Icon - SSO icon images
// +Font Sizing - Mixin
// ====================
@@ -448,3 +449,17 @@
padding: 0;
margin: 0;
}
// * +Icon - SSO icon images
// ====================
%sso-icon {
.icon-image {
width: auto;
height: auto;
max-height: 1.4em;
max-width: 1.4em;
margin-top: -2px;
}
}

View File

@@ -166,8 +166,11 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
# Create a user account
email, password = self._create_unique_user()
# Navigate to the login page and try to log in using "Dummy" provider
# Navigate to the login page
self.login_page.visit()
self.assertScreenshot('#login .login-providers', 'login-providers')
# Try to log in using "Dummy" provider
self.login_page.click_third_party_dummy_provider()
# The user will be redirected somewhere and then back to the login page:
@@ -206,6 +209,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
# We should now be redirected to the login page
self.login_page.wait_for_page()
self.assertIn("Would you like to sign in using your Dummy credentials?", self.login_page.hinted_login_prompt)
self.assertScreenshot('#hinted-login-form', 'hinted-login')
self.login_page.click_third_party_dummy_provider()
# We should now be redirected to the course page
@@ -329,8 +333,11 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
Test that we can register using third party credentials, and that the
third party account gets linked to the edX account.
"""
# Navigate to the register page and try to authenticate using the "Dummy" provider
# Navigate to the register page
self.register_page.visit()
self.assertScreenshot('#register .login-providers', 'register-providers')
# Try to authenticate using the "Dummy" provider
self.register_page.click_third_party_dummy_provider()
# The user will be redirected somewhere and then back to the register page:

View File

@@ -3,9 +3,8 @@
End-to-end tests for the LMS Instructor Dashboard.
"""
import time
import ddt
from flaky import flaky
from nose.plugins.attrib import attr
from bok_choy.promise import EmptyPromise
@@ -652,6 +651,7 @@ class DataDownloadsTest(BaseInstructorDashboardTest):
@attr('shard_7')
@ddt.ddt
class CertificatesTest(BaseInstructorDashboardTest):
"""
Tests for Certificates functionality on instructor dashboard.
@@ -907,6 +907,36 @@ class CertificatesTest(BaseInstructorDashboardTest):
self.certificates_section.message.text
)
@ddt.data(
('Test \nNotes', 'Test Notes'),
('<Test>Notes</Test>', '<Test>Notes</Test>'),
)
@ddt.unpack
def test_notes_escaped_in_add_certificate_exception(self, notes, expected_notes):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can add new certificate
exception to list.
Given that I am on the Certificates tab on the Instructor Dashboard
When I fill in student username and notes (which contains character which are needed to be escaped)
and click 'Add Exception' button, then new certificate exception should be visible in
certificate exceptions list.
"""
# Add a student to Certificate exception list
self.certificates_section.add_certificate_exception(self.user_name, notes)
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
self.assertIn(expected_notes, self.certificates_section.last_certificate_exception.text)
# Revisit Page & verify that added exceptions are also synced with backend
self.certificates_section.refresh()
# Wait for the certificate exception section to render
self.certificates_section.wait_for_certificate_exceptions_section()
# Validate certificate exception synced with server is visible in certificate exceptions list
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
self.assertIn(expected_notes, self.certificates_section.last_certificate_exception.text)
@attr('shard_7')
class CertificateInvalidationTest(BaseInstructorDashboardTest):

View File

@@ -8,6 +8,7 @@
"changed_by": null,
"name": "Google",
"icon_class": "fa-google-plus",
"icon_image": null,
"backend_name": "google-oauth2",
"key": "test",
"secret": "test",
@@ -23,6 +24,7 @@
"changed_by": null,
"name": "Facebook",
"icon_class": "fa-facebook",
"icon_image": null,
"backend_name": "facebook",
"key": "test",
"secret": "test",
@@ -37,7 +39,8 @@
"change_date": "2001-02-03T04:05:06Z",
"changed_by": null,
"name": "Dummy",
"icon_class": "fa-sign-in",
"icon_class": "",
"icon_image": "test-icon.png",
"backend_name": "dummy",
"key": "",
"secret": "",

View File

@@ -216,11 +216,6 @@ def perform_delegate_email_batches(entry_id, course_id, task_input, action_name)
# Fetch the course object.
course = get_course(course_id)
if course is None:
msg = u"Task %s: course not found: %s"
log.error(msg, task_id, course_id)
raise ValueError(msg % (task_id, course_id))
# Get arguments that will be passed to every subtask.
to_option = email_obj.to_option
global_email_context = _get_course_email_context(course)
@@ -403,11 +398,32 @@ def _get_source_address(course_id, course_title):
# For the email address, get the course. Then make sure that it can be used
# in an email address, by substituting a '_' anywhere a non-(ascii, period, or dash)
# character appears.
from_addr = u'"{0}" Course Staff <{1}-{2}>'.format(
course_title_no_quotes,
re.sub(r"[^\w.-]", '_', course_id.course),
settings.BULK_EMAIL_DEFAULT_FROM_EMAIL
)
course_name = re.sub(r"[^\w.-]", '_', course_id.course)
from_addr_format = u'"{course_title}" Course Staff <{course_name}-{from_email}>'
def format_address(course_title_no_quotes):
"""
Partial function for formatting the from_addr. Since
`course_title_no_quotes` may be truncated to make sure the returned
string has fewer than 320 characters, we define this function to make
it easy to determine quickly what the max length is for
`course_title_no_quotes`.
"""
return from_addr_format.format(
course_title=course_title_no_quotes,
course_name=course_name,
from_email=settings.BULK_EMAIL_DEFAULT_FROM_EMAIL,
)
from_addr = format_address(course_title_no_quotes)
# If it's longer than 320 characters, reformat, but with the course name
# rather than course title. Amazon SES's from address field appears to have a maximum
# length of 320.
if len(from_addr) >= 320:
from_addr = format_address(course_name)
return from_addr

View File

@@ -20,7 +20,8 @@ from instructor_task.subtasks import update_subtask_status
from student.roles import CourseStaffRole
from student.models import CourseEnrollment
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
STAFF_COUNT = 3
@@ -44,44 +45,77 @@ class MockCourseEmailResult(object):
return mock_update_subtask_status
class EmailSendFromDashboardTestCase(ModuleStoreTestCase):
class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase):
"""
Test that emails send correctly.
"""
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def setUp(self):
super(EmailSendFromDashboardTestCase, self).setUp()
course_title = u"ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ"
self.course = CourseFactory.create(display_name=course_title)
def create_staff_and_instructor(self):
"""
Creates one instructor and several course staff for self.course. Assigns
them to self.instructor (single user) and self.staff (list of users),
respectively.
"""
self.instructor = InstructorFactory(course_key=self.course.id)
# Create staff
self.staff = [StaffFactory(course_key=self.course.id)
for _ in xrange(STAFF_COUNT)]
self.staff = [
StaffFactory(course_key=self.course.id) for __ in xrange(STAFF_COUNT)
]
# Create students
def create_students(self):
"""
Creates users and enrolls them in self.course. Assigns these users to
self.students.
"""
self.students = [UserFactory() for _ in xrange(STUDENT_COUNT)]
for student in self.students:
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
def login_as_user(self, user):
"""
Log in self.client as user.
"""
self.client.login(username=user.username, password="test")
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def goto_instructor_dash_email_view(self):
"""
Goes to the instructor dashboard to verify that the email section is
there.
"""
url = reverse('instructor_dashboard', kwargs={'course_id': unicode(self.course.id)})
# Response loads the whole instructor dashboard, so no need to explicitly
# navigate to a particular email section
response = self.client.get(url)
email_section = '<div class="vert-left send-email" id="section-send-email">'
# If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False
self.assertIn(email_section, response.content)
@classmethod
def setUpClass(cls):
super(EmailSendFromDashboardTestCase, cls).setUpClass()
course_title = u"ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ"
cls.course = CourseFactory.create(
display_name=course_title,
default_store=ModuleStoreEnum.Type.split
)
def setUp(self):
super(EmailSendFromDashboardTestCase, self).setUp()
self.create_staff_and_instructor()
self.create_students()
# 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")
self.login_as_user(self.instructor)
# Pull up email view on instructor dashboard
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
# Response loads the whole instructor dashboard, so no need to explicitly
# navigate to a particular email section
response = self.client.get(self.url)
email_section = '<div class="vert-left send-email" id="section-send-email">'
# If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False
self.assertTrue(email_section in response.content)
self.send_mail_url = reverse('send_email', kwargs={'course_id': self.course.id.to_deprecated_string()})
self.goto_instructor_dash_email_view()
self.send_mail_url = reverse(
'send_email', kwargs={'course_id': unicode(self.course.id)}
)
self.success_content = {
'course_id': self.course.id.to_deprecated_string(),
'course_id': unicode(self.course.id),
'success': True,
}
@@ -130,6 +164,13 @@ class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase)
self.assertEqual(len(mail.outbox[0].to), 1)
self.assertEquals(mail.outbox[0].to[0], self.instructor.email)
self.assertEquals(mail.outbox[0].subject, 'test subject for myself')
self.assertEquals(
mail.outbox[0].from_email,
u'"{course_display_name}" Course Staff <{course_name}-no-reply@example.com>'.format(
course_display_name=self.course.display_name,
course_name=self.course.id.course
)
)
def test_send_to_staff(self):
"""
@@ -268,6 +309,42 @@ class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase)
[self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students]
)
def test_long_course_display_name(self):
"""
This test tests that courses with exorbitantly large display names
can still send emails, since it appears that 320 appears to be the
character length limit of from emails for Amazon SES.
"""
test_email = {
'action': 'Send email',
'send_to': 'myself',
'subject': 'test subject for self',
'message': 'test message for self'
}
# make very long display_name for course
long_name = u"x" * 321
course = CourseFactory.create(
display_name=long_name, number="bulk_email_course_name"
)
instructor = InstructorFactory(course_key=course.id)
self.login_as_user(instructor)
send_mail_url = reverse('send_email', kwargs={'course_id': unicode(course.id)})
response = self.client.post(send_mail_url, test_email)
self.assertTrue(json.loads(response.content)['success'])
self.assertEqual(len(mail.outbox), 1)
from_email = mail.outbox[0].from_email
self.assertEqual(
from_email,
u'"{course_name}" Course Staff <{course_name}-no-reply@example.com>'.format(
course_name=course.id.course
)
)
self.assertEqual(len(from_email), 83)
@override_settings(BULK_EMAIL_EMAILS_PER_TASK=3)
@patch('bulk_email.tasks.update_subtask_status')
def test_chunked_queries_send_numerous_emails(self, email_mock):

View File

@@ -260,5 +260,10 @@ def generate_certificate_for_user(request):
return HttpResponseBadRequest(msg)
# Attempt to generate certificate
generate_certificates_for_students(request, params["course_key"], students=[params["user"]])
generate_certificates_for_students(
request,
params["course_key"],
student_set="specific_student",
specific_student_id=params["user"].id
)
return HttpResponse(200)

View File

@@ -217,6 +217,27 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
return ecommerce_service.checkout_page_url(course_mode.sku)
return reverse('verify_student_upgrade_and_verify', args=(self.course.id,))
@property
def is_enabled(self):
"""
Whether or not this summary block should be shown.
By default, the summary is only shown if it has date and the date is in the
future and the user's enrollment is in upsell modes
"""
is_enabled = super(VerifiedUpgradeDeadlineDate, self).is_enabled
if not is_enabled:
return False
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
# Return `true` if user is not enrolled in course
if enrollment_mode is None and is_active is None:
return True
# Show the summary if user enrollment is in which allow user to upsell
return is_active and enrollment_mode in CourseMode.UPSELL_TO_VERIFIED_MODES
@lazy
def date(self):
try:

View File

@@ -42,7 +42,9 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
days_till_start=1,
days_till_end=14,
days_till_upgrade_deadline=4,
enroll_user=True,
enrollment_mode=CourseMode.VERIFIED,
course_min_price=100,
days_till_verification_deadline=14,
verification_status=None,
sku=None
@@ -64,11 +66,13 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
course_id=self.course.id,
mode_slug=enrollment_mode,
expiration_datetime=now + timedelta(days=days_till_upgrade_deadline),
min_price=course_min_price,
sku=sku
)
if enroll_user:
enrollment_mode = enrollment_mode or CourseMode.DEFAULT_MODE_SLUG
CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user, mode=enrollment_mode)
else:
CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user)
if days_till_verification_deadline is not None:
VerificationDeadline.objects.create(
@@ -95,21 +99,36 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
self.assertEqual(set(type(b) for b in blocks), set(expected_blocks))
@ddt.data(
# Before course starts
({}, (CourseEndDate, CourseStartDate, TodaysDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate)),
# After course end
# Verified enrollment with no photo-verification before course start
({}, (CourseEndDate, CourseStartDate, TodaysDate, VerificationDeadlineDate)),
# Verified enrollment with `approved` photo-verification after course end
({'days_till_start': -10,
'days_till_end': -5,
'days_till_upgrade_deadline': -6,
'days_till_verification_deadline': -5,
'verification_status': 'approved'},
(TodaysDate, CourseEndDate)),
# No course end date
# Verified enrollment with `expired` photo-verification during course run
({'days_till_start': -10,
'verification_status': 'expired'},
(TodaysDate, CourseEndDate, VerificationDeadlineDate)),
# Verified enrollment with `approved` photo-verification during course run
({'days_till_start': -10,
'verification_status': 'approved'},
(TodaysDate, CourseEndDate)),
# Audit enrollment and non-upsell course.
({'days_till_start': -10,
'days_till_upgrade_deadline': None,
'days_till_verification_deadline': None,
'course_min_price': 0,
'enrollment_mode': CourseMode.AUDIT},
(TodaysDate, CourseEndDate)),
# Verified enrollment with *NO* course end date
({'days_till_end': None},
(CourseStartDate, TodaysDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate)),
# During course run
(CourseStartDate, TodaysDate, VerificationDeadlineDate)),
# Verified enrollment with no photo-verification during course run
({'days_till_start': -1},
(TodaysDate, CourseEndDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate)),
(TodaysDate, CourseEndDate, VerificationDeadlineDate)),
# Verification approved
({'days_till_start': -10,
'days_till_upgrade_deadline': -1,
@@ -117,13 +136,26 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'verification_status': 'approved'},
(TodaysDate, CourseEndDate)),
# After upgrade deadline
({'days_till_start': -10, 'days_till_upgrade_deadline': -1},
({'days_till_start': -10,
'days_till_upgrade_deadline': -1},
(TodaysDate, CourseEndDate, VerificationDeadlineDate)),
# After verification deadline
({'days_till_start': -10,
'days_till_upgrade_deadline': -2,
'days_till_verification_deadline': -1},
(TodaysDate, CourseEndDate, VerificationDeadlineDate))
(TodaysDate, CourseEndDate, VerificationDeadlineDate)),
# Un-enrolled user before course start
({'enroll_user': False},
(CourseStartDate, TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)),
# Un-enrolled user during course run
({'days_till_start': -1,
'enroll_user': False},
(TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)),
# Un-enrolled user after course end.
({'enroll_user': False,
'days_till_start': -10,
'days_till_end': -5},
(TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)),
)
@ddt.unpack
def test_enabled_block_types(self, course_options, expected_blocks):

View File

@@ -720,7 +720,6 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase):
response = self.client.post(
url,
data=json.dumps([self.certificate_exception]),
content_type='application/json'
)
# Assert Success
@@ -736,24 +735,49 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase):
u"Certificate generation started for white listed students."
)
def test_generate_certificate_exceptions_invalid_user_list_error(self):
def test_generate_certificate_exceptions_whitelist_not_generated(self):
"""
Test generate certificates exceptions api endpoint returns error
when called with certificate exceptions with empty 'user_id' field
Test generate certificates exceptions api endpoint returns success
when calling with new certificate exception.
"""
url = reverse(
'generate_certificate_exceptions',
kwargs={'course_id': unicode(self.course.id), 'generate_for': 'new'}
)
# assign empty user_id
self.certificate_exception.update({'user_id': ''})
response = self.client.post(
url,
content_type='application/json'
)
# Assert Success
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
# Assert Request is successful
self.assertTrue(res_json['success'])
# Assert Message
self.assertEqual(
res_json['message'],
u"Certificate generation started for white listed students."
)
def test_generate_certificate_exceptions_generate_for_incorrect_value(self):
"""
Test generate certificates exceptions api endpoint returns error
when calling with generate_for without 'new' or 'all' value.
"""
url = reverse(
'generate_certificate_exceptions',
kwargs={'course_id': unicode(self.course.id), 'generate_for': ''}
)
response = self.client.post(
url,
data=json.dumps([self.certificate_exception]),
content_type='application/json'
)
# Assert Failure
self.assertEqual(response.status_code, 400)
@@ -764,7 +788,7 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase):
# Assert Message
self.assertEqual(
res_json['message'],
u"Invalid data, user_id must be present for all certificate exceptions."
u'Invalid data, generate_for must be "new" or "all".'
)

View File

@@ -3032,41 +3032,24 @@ def generate_certificate_exceptions(request, course_id, generate_for=None):
"""
course_key = CourseKey.from_string(course_id)
try:
certificate_white_list = json.loads(request.body)
except ValueError:
return JsonResponse({
'success': False,
'message': _('Invalid Json data, Please refresh the page and then try again.')
}, status=400)
users = [exception.get('user_id', False) for exception in certificate_white_list]
if generate_for == 'all':
# Generate Certificates for all white listed students
students = User.objects.filter(
certificatewhitelist__course_id=course_key,
certificatewhitelist__whitelist=True
)
elif not all(users):
# Invalid data, user_id must be present for all certificate exceptions
students = 'all_whitelisted'
elif generate_for == 'new':
students = 'whitelisted_not_generated'
else:
# Invalid data, generate_for must be present for all certificate exceptions
return JsonResponse(
{
'success': False,
'message': _('Invalid data, user_id must be present for all certificate exceptions.'),
'message': _('Invalid data, generate_for must be "new" or "all".'),
},
status=400
)
else:
students = User.objects.filter(
id__in=users,
certificatewhitelist__course_id=course_key,
certificatewhitelist__whitelist=True
)
if students:
# generate certificates for students if 'students' list is not empty
instructor_task.api.generate_certificates_for_students(request, course_key, students=students)
instructor_task.api.generate_certificates_for_students(request, course_key, student_set=students)
response_payload = {
'success': True,
@@ -3275,8 +3258,10 @@ def re_validate_certificate(request, course_key, generated_certificate):
certificate_invalidation.deactivate()
# We need to generate certificate only for a single student here
students = [certificate_invalidation.generated_certificate.user]
instructor_task.api.generate_certificates_for_students(request, course_key, students=students)
student = certificate_invalidation.generated_certificate.user
instructor_task.api.generate_certificates_for_students(
request, course_key, student_set="specific_student", specific_student_id=student.id
)
def validate_request_data_and_get_certificate(certificate_invalidation, course_key):

View File

@@ -45,6 +45,13 @@ from bulk_email.models import CourseEmail
from util import milestones_helpers
class SpecificStudentIdMissingError(Exception):
"""
Exception indicating that a student id was not provided when generating a certificate for a specific student.
"""
pass
def get_running_instructor_tasks(course_id):
"""
Returns a query of InstructorTask objects of running tasks for a given course.
@@ -437,17 +444,34 @@ def submit_export_ora2_data(request, course_key):
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
def generate_certificates_for_students(request, course_key, students=None): # pylint: disable=invalid-name
def generate_certificates_for_students(request, course_key, student_set=None, specific_student_id=None): # pylint: disable=invalid-name
"""
Submits a task to generate certificates for given students enrolled in the course or
all students if argument 'students' is None
Submits a task to generate certificates for given students enrolled in the course.
Arguments:
course_key : Course Key
student_set : Semantic for student collection for certificate generation.
Options are:
'all_whitelisted': All Whitelisted students.
'whitelisted_not_generated': Whitelisted students which does not got certificates yet.
'specific_student': Single student for certificate generation.
specific_student_id : Student ID when student_set is 'specific_student'
Raises AlreadyRunningError if certificates are currently being generated.
Raises SpecificStudentIdMissingError if student_set is 'specific_student' and specific_student_id is 'None'
"""
if students:
task_type = 'generate_certificates_certain_student'
students = [student.id for student in students]
task_input = {'students': students}
if student_set:
task_type = 'generate_certificates_student_set'
task_input = {'student_set': student_set}
if student_set == 'specific_student':
task_type = 'generate_certificates_certain_student'
if specific_student_id is None:
raise SpecificStudentIdMissingError(
"Attempted to generate certificate for a single student, "
"but no specific student id provided"
)
task_input.update({'specific_student_id': specific_student_id})
else:
task_type = 'generate_certificates_all_student'
task_input = {}
@@ -466,22 +490,16 @@ def generate_certificates_for_students(request, course_key, students=None): # p
return instructor_task
def regenerate_certificates(request, course_key, statuses_to_regenerate, students=None):
def regenerate_certificates(request, course_key, statuses_to_regenerate):
"""
Submits a task to regenerate certificates for given students enrolled in the course or
all students if argument 'students' is None.
Submits a task to regenerate certificates for given students enrolled in the course.
Regenerate Certificate only if the status of the existing generated certificate is in 'statuses_to_regenerate'
list passed in the arguments.
Raises AlreadyRunningError if certificates are currently being generated.
"""
if students:
task_type = 'regenerate_certificates_certain_student'
students = [student.id for student in students]
task_input = {'students': students}
else:
task_type = 'regenerate_certificates_all_student'
task_input = {}
task_type = 'regenerate_certificates_all_student'
task_input = {}
task_input.update({"statuses_to_regenerate": statuses_to_regenerate})
task_class = generate_certificates

View File

@@ -1409,30 +1409,56 @@ def generate_students_certificates(
json column, otherwise generate certificates for all enrolled students.
"""
start_time = time()
enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id)
students_to_generate_certs_for = CourseEnrollment.objects.users_enrolled_in(course_id)
students = task_input.get('students', None)
student_set = task_input.get('student_set')
if student_set == 'all_whitelisted':
# Generate Certificates for all white listed students.
students_to_generate_certs_for = students_to_generate_certs_for.filter(
certificatewhitelist__course_id=course_id,
certificatewhitelist__whitelist=True
)
if students is not None:
enrolled_students = enrolled_students.filter(id__in=students)
elif student_set == 'whitelisted_not_generated':
# All Whitelisted students
students_to_generate_certs_for = students_to_generate_certs_for.filter(
certificatewhitelist__course_id=course_id,
certificatewhitelist__whitelist=True
)
task_progress = TaskProgress(action_name, enrolled_students.count(), start_time)
# Whitelisted students which got certificates already.
certificate_generated_students = GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id=course_id,
)
certificate_generated_students_ids = set(certificate_generated_students.values_list('user_id', flat=True))
students_to_generate_certs_for = students_to_generate_certs_for.exclude(
id__in=certificate_generated_students_ids
)
elif student_set == "specific_student":
specific_student_id = task_input.get('specific_student_id')
students_to_generate_certs_for = students_to_generate_certs_for.filter(id=specific_student_id)
task_progress = TaskProgress(action_name, students_to_generate_certs_for.count(), start_time)
current_step = {'step': 'Calculating students already have certificates'}
task_progress.update_task_state(extra_meta=current_step)
statuses_to_regenerate = task_input.get('statuses_to_regenerate', [])
if students is not None and not statuses_to_regenerate:
if student_set is not None and not statuses_to_regenerate:
# We want to skip 'filtering students' only when students are given and statuses to regenerate are not
students_require_certs = enrolled_students
students_require_certs = students_to_generate_certs_for
else:
students_require_certs = students_require_certificate(course_id, enrolled_students, statuses_to_regenerate)
students_require_certs = students_require_certificate(
course_id, students_to_generate_certs_for, statuses_to_regenerate
)
if statuses_to_regenerate:
# Mark existing generated certificates as 'unavailable' before regenerating
# We need to call this method after "students_require_certificate" otherwise "students_require_certificate"
# would return no results.
invalidate_generated_certificates(course_id, enrolled_students, statuses_to_regenerate)
invalidate_generated_certificates(course_id, students_to_generate_certs_for, statuses_to_regenerate)
task_progress.skipped = task_progress.total - len(students_require_certs)

View File

@@ -24,6 +24,7 @@ from instructor_task.api import (
generate_certificates_for_students,
regenerate_certificates,
submit_export_ora2_data,
SpecificStudentIdMissingError,
)
from instructor_task.api_helper import AlreadyRunningError
@@ -295,6 +296,18 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
)
self._test_resubmission(api_call)
def test_certificate_generation_no_specific_student_id(self):
"""
Raises ValueError when student_set is 'specific_student' and 'specific_student_id' is None.
"""
with self.assertRaises(SpecificStudentIdMissingError):
generate_certificates_for_students(
self.create_task_request(self.instructor),
self.course.id,
student_set='specific_student',
specific_student_id=None
)
def test_certificate_generation_history(self):
"""
Tests that a new record is added whenever certificate generation/regeneration task is submitted.

View File

@@ -1628,8 +1628,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
Verify that certificates generated for all eligible students enrolled in a course.
"""
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
students = self._create_students(10)
# mark 2 students to have certificates generated already
for student in students[:2]:
@@ -1644,40 +1643,157 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
for student in students[2:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
instructor_task = Mock()
instructor_task.task_input = json.dumps({'students': None})
task_input = {'student_set': None}
expected_results = {
'action_name': 'certificates generated',
'total': 10,
'attempted': 8,
'succeeded': 5,
'failed': 3,
'skipped': 2
}
with self.assertNumQueries(214):
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued")
with patch('instructor_task.models.InstructorTask.objects.get') as instructor_task_object:
instructor_task_object.return_value = instructor_task
result = generate_students_certificates(
None, None, self.course.id, {}, 'certificates generated'
)
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 8,
'succeeded': 5,
'failed': 3,
'skipped': 2
},
result
self.assertCertificatesGenerated(task_input, expected_results)
def test_certificate_generation_all_whitelisted(self):
"""
Verify that certificates generated for all white-listed students when using semantic task_input as
`all_whitelisted`.
"""
# create 5 students
students = self._create_students(5)
# white-list 5 students
for student in students:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
task_input = {'student_set': 'all_whitelisted'}
expected_results = {
'action_name': 'certificates generated',
'total': 5,
'attempted': 5,
'succeeded': 5,
'failed': 0,
'skipped': 0
}
self.assertCertificatesGenerated(task_input, expected_results)
def test_certificate_generation_whitelist_already_generated(self):
"""
Verify that certificates generated for all white-listed students having certifcates already when using
semantic task_input as `all_whitelisted`.
"""
# create 5 students
students = self._create_students(5)
# white-list 5 students
for student in students:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
# mark 5 students to have certificates generated already
for student in students:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor'
)
task_input = {'student_set': 'all_whitelisted'}
expected_results = {
'action_name': 'certificates generated',
'total': 5,
'attempted': 5,
'succeeded': 5,
'failed': 0,
'skipped': 0
}
self.assertCertificatesGenerated(task_input, expected_results)
def test_certificate_generation_whitelisted_not_generated(self):
"""
Verify that certificates only generated for those students which does not have certificates yet when
using semantic task_input as `whitelisted_not_generated`.
"""
# create 5 students
students = self._create_students(5)
# mark 2 students to have certificates generated already
for student in students[:2]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor'
)
# white-list 5 students
for student in students:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
task_input = {'student_set': 'whitelisted_not_generated'}
expected_results = {
'action_name': 'certificates generated',
'total': 3,
'attempted': 3,
'succeeded': 3,
'failed': 0,
'skipped': 0
}
self.assertCertificatesGenerated(
task_input,
expected_results
)
def test_certificate_generation_specific_student(self):
"""
Tests generating a certificate for a specific student.
"""
student = self.create_student(username="Hamnet", email="ham@ardenforest.co.uk")
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
task_input = {
'student_set': 'specific_student',
'specific_student_id': student.id
}
expected_results = {
'action_name': 'certificates generated',
'total': 1,
'attempted': 1,
'succeeded': 1,
'failed': 0,
'skipped': 0,
}
self.assertCertificatesGenerated(task_input, expected_results)
def test_specific_student_not_enrolled(self):
"""
Tests generating a certificate for a specific student if that student
is not enrolled in the course.
"""
student = self.create_student(username="jacques", email="antlers@ardenforest.co.uk")
task_input = {
'student_set': 'specific_student',
'specific_student_id': student.id
}
expected_results = {
'action_name': 'certificates generated',
'total': 1,
'attempted': 1,
'succeeded': 0,
'failed': 1,
'skipped': 0,
}
self.assertCertificatesGenerated(task_input, expected_results)
def test_certificate_regeneration_for_statuses_to_regenerate(self):
"""
Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
certificate statuses lies in the list 'statuses_to_regenerate' given in task_input.
"""
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
students = self._create_students(10)
# mark 2 students to have certificates generated already
for student in students[:2]:
@@ -1710,31 +1826,22 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
for student in students[:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
# Certificates should be regenerated for students having generated certificates with status
# 'downloadable' or 'error' which are total of 5 students in this test case
task_input = {'statuses_to_regenerate': [CertificateStatuses.downloadable, CertificateStatuses.error]}
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued")
result = generate_students_certificates(
None, None, self.course.id, task_input, 'certificates generated'
)
expected_results = {
'action_name': 'certificates generated',
'total': 10,
'attempted': 5,
'succeeded': 5,
'failed': 0,
'skipped': 5
}
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 5,
'succeeded': 5,
'failed': 0,
'skipped': 5
},
result
self.assertCertificatesGenerated(
task_input,
expected_results
)
def test_certificate_regeneration_with_expected_failures(self):
@@ -1746,8 +1853,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
default_grade = '-1'
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
students = self._create_students(10)
# mark 2 students to have certificates generated already
for student in students[:2]:
@@ -1796,32 +1902,21 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
for student in students[:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
# Regenerated certificates for students having generated certificates with status
# 'deleted' or 'generating'
task_input = {'statuses_to_regenerate': [CertificateStatuses.deleted, CertificateStatuses.generating]}
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued")
result = generate_students_certificates(
None, None, self.course.id, task_input, 'certificates generated'
)
expected_results = {
'action_name': 'certificates generated',
'total': 10,
'attempted': 5,
'succeeded': 2,
'failed': 3,
'skipped': 5
}
self.assertCertificatesGenerated(task_input, expected_results)
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 5,
'succeeded': 2,
'failed': 3,
'skipped': 5
},
result
)
generated_certificates = GeneratedCertificate.eligible_certificates.filter(
user__in=students,
course_id=self.course.id,
@@ -1852,8 +1947,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
default_grade = '-1'
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
students = self._create_students(10)
# mark 2 students to have certificates generated already
for student in students[:2]:
@@ -1899,9 +1993,6 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
for student in students[:]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
# Regenerated certificates for students having generated certificates with status
# 'downloadable', 'error' or 'generating'
task_input = {
@@ -1912,24 +2003,18 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
]
}
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued")
result = generate_students_certificates(
None, None, self.course.id, task_input, 'certificates generated'
)
expected_results = {
'action_name': 'certificates generated',
'total': 10,
'attempted': 8,
'succeeded': 8,
'failed': 0,
'skipped': 2
}
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 8,
'succeeded': 8,
'failed': 0,
'skipped': 2
},
result
self.assertCertificatesGenerated(
task_input,
expected_results
)
generated_certificates = GeneratedCertificate.eligible_certificates.filter(
@@ -1963,8 +2048,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
Verify that certificates are regenerated for all students passed in task_input.
"""
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
students = self._create_students(10)
# mark 2 students to have certificates generated already
for student in students[:2]:
@@ -2006,12 +2090,27 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
for student in students[:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
# Certificates should be regenerated for students having generated certificates with status
# 'downloadable' or 'error' which are total of 5 students in this test case
task_input = {'students': [student.id for student in students]}
task_input = {'student_set': "all_whitelisted"}
expected_results = {
'action_name': 'certificates generated',
'total': 7,
'attempted': 7,
'succeeded': 7,
'failed': 0,
'skipped': 0,
}
self.assertCertificatesGenerated(task_input, expected_results)
def assertCertificatesGenerated(self, task_input, expected_results):
"""
Generate certificates for the given task_input and compare with expected_results.
"""
current_task = Mock()
current_task.update_state = Mock()
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
@@ -2022,17 +2121,22 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
)
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 10,
'succeeded': 7,
'failed': 3,
'skipped': 0,
},
expected_results,
result
)
def _create_students(self, number_of_students):
"""
Create Students for course.
"""
return [
self.create_student(
username='student_{}'.format(index),
email='student_{}@example.com'.format(index)
)
for index in xrange(number_of_students)
]
class TestInstructorOra2Report(SharedModuleStoreTestCase):
"""

View File

@@ -4,13 +4,13 @@
import re
from unittest import skipUnless
from urllib import urlencode
import json
import mock
import ddt
from django.conf import settings
from django.core.urlresolvers import reverse
from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.urlresolvers import reverse
from django.contrib import messages
from django.contrib.messages.middleware import MessageMiddleware
from django.test import TestCase
@@ -214,9 +214,15 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
def setUp(self):
super(StudentAccountLoginAndRegistrationTest, self).setUp('embargo')
# For these tests, two third party auth providers are enabled by default:
# For these tests, three third party auth providers are enabled by default:
self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
self.configure_dummy_provider(
enabled=True,
icon_class='',
icon_image=SimpleUploadedFile('icon.svg', '<svg><rect width="50" height="100"/></svg>'),
)
@ddt.data(
("signin_user", "login"),
@@ -290,6 +296,8 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
("register_user", "google-oauth2", "Google"),
("signin_user", "facebook", "Facebook"),
("register_user", "facebook", "Facebook"),
("signin_user", "dummy", "Dummy"),
("register_user", "dummy", "Dummy"),
)
@ddt.unpack
def test_third_party_auth(self, url_name, current_backend, current_provider):
@@ -313,10 +321,19 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
# This relies on the THIRD_PARTY_AUTH configuration in the test settings
expected_providers = [
{
"id": "oa2-dummy",
"name": "Dummy",
"iconClass": None,
"iconImage": settings.MEDIA_URL + "icon.svg",
"loginUrl": self._third_party_login_url("dummy", "login", params),
"registerUrl": self._third_party_login_url("dummy", "register", params)
},
{
"id": "oa2-facebook",
"name": "Facebook",
"iconClass": "fa-facebook",
"iconImage": None,
"loginUrl": self._third_party_login_url("facebook", "login", params),
"registerUrl": self._third_party_login_url("facebook", "register", params)
},
@@ -324,9 +341,10 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
"id": "oa2-google-oauth2",
"name": "Google",
"iconClass": "fa-google-plus",
"iconImage": None,
"loginUrl": self._third_party_login_url("google-oauth2", "login", params),
"registerUrl": self._third_party_login_url("google-oauth2", "register", params)
}
},
]
self._assert_third_party_auth_data(response, current_backend, current_provider, expected_providers)

View File

@@ -198,7 +198,8 @@ def _third_party_auth_context(request, redirect_to):
info = {
"id": enabled.provider_id,
"name": enabled.name,
"iconClass": enabled.icon_class,
"iconClass": enabled.icon_class or None,
"iconImage": enabled.icon_image.url if enabled.icon_image else None,
"loginUrl": pipeline.get_login_url(
enabled.provider_id,
pipeline.AUTH_ENTRY_LOGIN,

View File

@@ -574,6 +574,7 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
'social.backends.google.GoogleOAuth2',
'social.backends.linkedin.LinkedinOAuth2',
'social.backends.facebook.FacebookOAuth2',
'social.backends.azuread.AzureADOAuth2',
'third_party_auth.saml.SAMLAuthBackend',
'third_party_auth.lti.LTIAuthBackend',
]) + list(AUTHENTICATION_BACKENDS)

View File

@@ -96,7 +96,7 @@
"LOCAL_LOGLEVEL": "INFO",
"LOGGING_ENV": "sandbox",
"LOG_DIR": "** OVERRIDDEN **",
"MEDIA_URL": "",
"MEDIA_URL": "/media/",
"MKTG_URL_LINK_MAP": {
"ABOUT": "about",
"PRIVACY": "privacy",

View File

@@ -67,7 +67,6 @@ STATICFILES_DIRS = (
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = TEST_ROOT / "uploads"
MEDIA_URL = "/static/uploads/"
# Don't use compression during tests
PIPELINE_JS_COMPRESSOR = None

View File

@@ -265,6 +265,7 @@ AUTHENTICATION_BACKENDS = (
'social.backends.google.GoogleOAuth2',
'social.backends.linkedin.LinkedinOAuth2',
'social.backends.facebook.FacebookOAuth2',
'social.backends.azuread.AzureADOAuth2',
'social.backends.twitter.TwitterOAuth',
'third_party_auth.dummy.DummyBackend',
'third_party_auth.saml.SAMLAuthBackend',

View File

@@ -16,7 +16,7 @@
return function(certificate_white_list_json, generate_certificate_exceptions_url,
certificate_exception_view_url, generate_bulk_certificate_exceptions_url){
var certificateWhiteList = new CertificateWhiteListCollection(JSON.parse(certificate_white_list_json), {
var certificateWhiteList = new CertificateWhiteListCollection(certificate_white_list_json, {
parse: true,
canBeEmpty: true,
url: certificate_exception_view_url,

View File

@@ -188,6 +188,8 @@ $ui-notification-height: ($baseline*10);
$twitter-blue: #55ACEE;
$facebook-blue: #3B5998;
$linkedin-blue: #0077B5;
$google-red: #DD4B39;
$microsoft-blue: #00BCF2;
// shadows
$shadow: rgba(0,0,0,0.2) !default;

View File

@@ -524,8 +524,9 @@
margin-right: ($baseline/2);
.icon {
color: inherit;
@extend %sso-icon;
@include margin-right($baseline/2);
color: inherit;
}
&:last-child {

View File

@@ -3,10 +3,6 @@
@import '../base/grid-settings';
@import "neat/neat"; // lib - Neat
$sm-btn-google: #dd4b39;
$sm-btn-facebook: #3b5998;
$sm-btn-linkedin: #0077b5;
.login-register {
@include box-sizing(border-box);
@include outer-container;
@@ -356,6 +352,10 @@ $sm-btn-linkedin: #0077b5;
text-transform: none;
font-weight: 600;
letter-spacing: normal;
.icon {
@extend %sso-icon;
}
}
.login-provider {
@@ -375,6 +375,7 @@ $sm-btn-linkedin: #0077b5;
text-transform: none;
.icon {
@extend %sso-icon;
@include left(0);
position: absolute;
@@ -403,17 +404,17 @@ $sm-btn-linkedin: #0077b5;
}
&.button-oa2-google-oauth2 {
color: $sm-btn-google;
color: $google-red;
.icon {
background: $sm-btn-google;
background: $google-red;
}
&:hover,
&:focus {
background-color: $sm-btn-google;
background-color: $google-red;
border: 1px solid #A5382B;
color: white;
color: $white;
}
&:hover {
@@ -422,17 +423,17 @@ $sm-btn-linkedin: #0077b5;
}
&.button-oa2-facebook {
color: $sm-btn-facebook;
color: $facebook-blue;
.icon {
background: $sm-btn-facebook;
background: $facebook-blue;
}
&:hover,
&:focus {
background-color: $sm-btn-facebook;
background-color: $facebook-blue;
border: 1px solid #263A62;
color: white;
color: $white;
}
&:hover {
@@ -441,17 +442,17 @@ $sm-btn-linkedin: #0077b5;
}
&.button-oa2-linkedin-oauth2 {
color: $sm-btn-linkedin;
color: $linkedin-blue;
.icon {
background: $sm-btn-linkedin;
background: $linkedin-blue;
}
&:hover,
&:focus {
background-color: $sm-btn-linkedin;
background-color: $linkedin-blue;
border: 1px solid #06527D;
color: white;
color: $white;
}
&:hover {
@@ -459,6 +460,25 @@ $sm-btn-linkedin: #0077b5;
}
}
&.button-oa2-azuread-oauth2 {
color: darken($microsoft-blue, 20%);
.icon {
background: $microsoft-blue;
}
&:hover,
&:focus {
background-color: $microsoft-blue;
border: 1px solid $microsoft-blue;
color: $white;
}
&:hover {
box-shadow: 0 2px 1px 0 darken($microsoft-blue, 10%);
}
}
}
.button-secondary-login {

View File

@@ -2,7 +2,8 @@
<p class="under-heading">
<label>
<input type='radio' name='generate-exception-certificates-radio' checked="checked" value='new' aria-describedby='generate-exception-certificates-radio-new-tip'>
<span id='generate-exception-certificates-radio-new-tip'><%- gettext('Generate a Certificate for all ') %><strong><%- gettext('New') %></strong> <%- gettext('additions to the Exception list') %></span>
<span id='generate-exception-certificates-radio-new-tip'><%- gettext('Generate certificates for all users on the Exception list for whom certificates have not yet been run') %></span>
</label>
<br/>
<label>

View File

@@ -7,7 +7,7 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str
%>
<%static:require_module module_name="js/certificates/factories/certificate_whitelist_factory" class_name="CertificateWhitelistFactory">
CertificateWhitelistFactory('${certificate_white_list | n, dump_js_escaped_json}', '${generate_certificate_exceptions_url | n, js_escaped_string}', '${certificate_exception_view_url | n, js_escaped_string}', '${generate_bulk_certificate_exceptions_url | n, js_escaped_string}');
CertificateWhitelistFactory(${certificate_white_list | n, dump_js_escaped_json}, '${generate_certificate_exceptions_url | n, js_escaped_string}', '${certificate_exception_view_url | n, js_escaped_string}', '${generate_bulk_certificate_exceptions_url | n, js_escaped_string}');
</%static:require_module>
<%static:require_module module_name="js/certificates/factories/certificate_invalidation_factory" class_name="CertificateInvalidationFactory">

View File

@@ -220,7 +220,14 @@ from third_party_auth import provider, pipeline
% for enabled in provider.Registry.accepting_logins():
## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn).
<button type="submit" class="button button-primary button-${enabled.provider_id} login-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.provider_id]}');"><span class="icon fa ${enabled.icon_class}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.name)}</button>
<button type="submit" class="button button-primary button-${enabled.provider_id} login-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.provider_id]}');">
% if enabled.icon_class:
<span class="icon fa ${enabled.icon_class}" aria-hidden="true"></span>
% else:
<span class="icon" aria-hidden="true"><img class="icon-image" src="${enabled.icon_image.url}" alt="${enabled.name} icon" /></span>
% endif
${_('Sign in with {provider_name}').format(provider_name=enabled.name)}
</button>
% endfor
</div>

View File

@@ -26,7 +26,14 @@ from student.models import UserProfile
% for enabled in provider.Registry.accepting_logins():
## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn).
<button type="submit" class="button button-primary button-${enabled.provider_id} register-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.provider_id]}');"><span class="icon fa ${enabled.icon_class}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.name)}</button>
<button type="submit" class="button button-primary button-${enabled.provider_id} register-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.provider_id]}');">
% if enabled.icon_class:
<span class="icon fa ${enabled.icon_class}" aria-hidden="true"></span>
% else:
<span class="icon" aria-hidden="true"><img class="icon-image" src="${enabled.icon_image.url}" alt="${enabled.name} icon" /></span>
% endif
${_('Sign up with {provider_name}').format(provider_name=enabled.name)}
</button>
% endfor
</div>

View File

@@ -8,7 +8,11 @@
<p class="instructions"><%- _.sprintf( gettext("Would you like to sign in using your %(providerName)s credentials?"), { providerName: hintedProvider.name } ) %></p>
<button class="action action-primary action-update proceed-button button-<%- hintedProvider.id %> hinted-login-<%- hintedProvider.id %>">
<div class="icon fa <%- hintedProvider.iconClass %>" aria-hidden="true"></div>
<span class="icon <% if ( hintedProvider.iconClass ) { %>fa <%- hintedProvider.iconClass %><% } %>" aria-hidden="true">
<% if ( hintedProvider.iconImage ) { %>
<img class="icon-image" src="<%- hintedProvider.iconImage %>" alt="<%- hintedProvider.name %> icon" />
<% } %>
</span>
<%- _.sprintf( gettext("Sign in using %(providerName)s"), { providerName: hintedProvider.name } ) %>
</button>

View File

@@ -61,7 +61,11 @@
<% _.each( context.providers, function( provider ) {
if ( provider.loginUrl ) { %>
<button type="button" class="button button-primary button-<%- provider.id %> login-provider login-<%- provider.id %>" data-provider-url="<%- provider.loginUrl %>">
<div class="icon fa <%- provider.iconClass %>" aria-hidden="true"></div>
<div class="icon <% if ( provider.iconClass ) { %>fa <%- provider.iconClass %><% } %>" aria-hidden="true">
<% if ( provider.iconImage ) { %>
<img class="icon-image" src="<%- provider.iconImage %>" alt="<%- provider.name %> icon" />
<% } %>
</div>
<span aria-hidden="true"><%- provider.name %></span>
<span class="sr"><%- _.sprintf( gettext("Sign in with %(providerName)s"), {providerName: provider.name} ) %></span>
</button>

View File

@@ -30,7 +30,11 @@
_.each( context.providers, function( provider) {
if ( provider.registerUrl ) { %>
<button type="button" class="button button-primary button-<%- provider.id %> login-provider register-<%- provider.id %>" data-provider-url="<%- provider.registerUrl %>">
<span class="icon fa <%- provider.iconClass %>" aria-hidden="true"></span>
<div class="icon <% if ( provider.iconClass ) { %>fa <%- provider.iconClass %><% } %>" aria-hidden="true">
<% if ( provider.iconImage ) { %>
<img class="icon-image" src="<%- provider.iconImage %>" alt="<%- provider.name %> icon" />
<% } %>
</div>
<span aria-hidden="true"><%- provider.name %></span>
<span class="sr"><%- _.sprintf( gettext("Create account using %(providerName)s."), {providerName: provider.name} ) %></span>
</button>

View File

@@ -943,6 +943,7 @@ urlpatterns = patterns(*urlpatterns)
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(
settings.PROFILE_IMAGE_BACKEND['options']['base_url'],
document_root=settings.PROFILE_IMAGE_BACKEND['options']['location']

View File

@@ -30,7 +30,8 @@ BOKCHOY_OPTS = [
make_option("-q", "--quiet", action="store_const", const=0, dest="verbosity"),
make_option("-v", "--verbosity", action="count", dest="verbosity"),
make_option("--pdb", action="store_true", help="Drop into debugger on failures or errors"),
make_option("--skip_firefox_version_validation", action='store_false', dest="validate_firefox_version")
make_option("--skip_firefox_version_validation", action='store_false', dest="validate_firefox_version"),
make_option("--save_screenshots", action='store_true', dest="save_screenshots"),
]
@@ -52,6 +53,7 @@ def parse_bokchoy_opts(options):
'extra_args': getattr(options, 'extra_args', ''),
'pdb': getattr(options, 'pdb', False),
'test_dir': getattr(options, 'test_dir', 'tests'),
'save_screenshots': getattr(options, 'save_screenshots', False),
}

View File

@@ -62,6 +62,7 @@ class BokChoyTestSuite(TestSuite):
self.a11y_file = Env.BOK_CHOY_A11Y_CUSTOM_RULES_FILE
self.imports_dir = kwargs.get('imports_dir', None)
self.coveragerc = kwargs.get('coveragerc', None)
self.save_screenshots = kwargs.get('save_screenshots', False)
def __enter__(self):
super(BokChoyTestSuite, self).__enter__()
@@ -234,6 +235,8 @@ class BokChoyTestSuite(TestSuite):
]
if self.pdb:
cmd.append("--pdb")
if self.save_screenshots:
cmd.append("--with-save-baseline")
cmd.append(self.extra_args)
cmd = (" ").join(cmd)

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -2,3 +2,5 @@
*.jpg
*.png
*.txt
*.svg
!test_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

View File

@@ -26,7 +26,14 @@ from student.models import UserProfile
% for enabled in provider.Registry.accepting_logins():
## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn).
<button type="submit" class="button button-primary button-${enabled.provider_id} register-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.provider_id]}');"><span class="icon fa ${enabled.icon_class}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.name)}</button>
<button type="submit" class="button button-primary button-${enabled.provider_id} register-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.provider_id]}');">
% if enabled.icon_class:
<span class="icon fa ${enabled.icon_class}" aria-hidden="true"></span>
% else:
<span class="icon" aria-hidden="true"><img class="icon-image" src="${enabled.icon_image.url}" alt="${enabled.name} icon" /></span>
% endif
${_('Sign up with {provider_name}').format(provider_name=enabled.name)}
</button>
% endfor
</div>