Commit 3853538c by Adam

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

Patch/2015 04 06
parents 0fd06e80 76057fc4
......@@ -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',
)
......
# -*- 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(
......
"""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')
"""
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()
......@@ -113,6 +125,16 @@ class ThirdPartyAuthTestMixin(object):
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 """
kwargs.setdefault("name", "Twitter")
......@@ -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)
# 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")
def login_as_user(self, user):
"""
Log in self.client as user.
"""
self.client.login(username=user.username, password="test")
# Pull up email view on instructor dashboard
self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
@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(self.url)
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.assertTrue(email_section in response.content)
self.send_mail_url = reverse('send_email', kwargs={'course_id': self.course.id.to_deprecated_string()})
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.login_as_user(self.instructor)
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:
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'
students = [student.id for student in students]
task_input = {'students': students}
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,20 +490,14 @@ 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 = {}
......
......@@ -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)
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
)
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
)
# 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 = task_input.get('students', None)
students_to_generate_certs_for = students_to_generate_certs_for.exclude(
id__in=certificate_generated_students_ids
)
if students is not None:
enrolled_students = enrolled_students.filter(id__in=students)
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, enrolled_students.count(), start_time)
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.
......
......@@ -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):
......@@ -314,9 +322,18 @@ 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)
......
......@@ -2,3 +2,5 @@
*.jpg
*.png
*.txt
*.svg
!test_icon.png
......@@ -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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment