@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
85
common/djangoapps/third_party_auth/tests/test_admin.py
Normal file
85
common/djangoapps/third_party_auth/tests/test_admin.py
Normal 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'])
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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".'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -524,8 +524,9 @@
|
||||
margin-right: ($baseline/2);
|
||||
|
||||
.icon {
|
||||
color: inherit;
|
||||
@extend %sso-icon;
|
||||
@include margin-right($baseline/2);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
screenshots/baseline/hinted-login.png
Normal file
BIN
screenshots/baseline/hinted-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
screenshots/baseline/login-providers.png
Normal file
BIN
screenshots/baseline/login-providers.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
screenshots/baseline/register-providers.png
Normal file
BIN
screenshots/baseline/register-providers.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
2
test_root/uploads/.gitignore
vendored
2
test_root/uploads/.gitignore
vendored
@@ -2,3 +2,5 @@
|
||||
*.jpg
|
||||
*.png
|
||||
*.txt
|
||||
*.svg
|
||||
!test_icon.png
|
||||
|
||||
BIN
test_root/uploads/test-icon.png
Normal file
BIN
test_root/uploads/test-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 B |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user