From c09a4c472f371befa0e8b8954c39b07b564e28c9 Mon Sep 17 00:00:00 2001 From: Omar Khan Date: Tue, 29 Mar 2016 10:44:12 +0700 Subject: [PATCH 1/8] Add --save_screenshots option to paver test_bokchoy --- pavelib/bok_choy.py | 4 +++- pavelib/utils/test/suites/bokchoy_suite.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pavelib/bok_choy.py b/pavelib/bok_choy.py index 3c4bb319fa..af76e7512a 100644 --- a/pavelib/bok_choy.py +++ b/pavelib/bok_choy.py @@ -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), } diff --git a/pavelib/utils/test/suites/bokchoy_suite.py b/pavelib/utils/test/suites/bokchoy_suite.py index bdf8566f8c..38461a311a 100644 --- a/pavelib/utils/test/suites/bokchoy_suite.py +++ b/pavelib/utils/test/suites/bokchoy_suite.py @@ -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) From 07892a75f29706c767700e95ab4f9f922ea6815e Mon Sep 17 00:00:00 2001 From: Omar Khan Date: Tue, 22 Mar 2016 16:19:26 +0700 Subject: [PATCH 2/8] Custom icons for third party auth login buttons - Icon images can be uploaded from the django admin - Test coverage improved --- cms/envs/bok_choy.env.json | 2 +- common/djangoapps/config_models/admin.py | 12 ++- common/djangoapps/third_party_auth/admin.py | 4 +- .../0002_schema__provider_icon_image.py | 44 +++++++++ common/djangoapps/third_party_auth/models.py | 33 ++++++- .../third_party_auth/tests/test_admin.py | 85 ++++++++++++++++++ .../third_party_auth/tests/testutil.py | 14 ++- common/static/sass/_mixins.scss | 15 ++++ common/test/acceptance/tests/lms/test_lms.py | 11 ++- common/test/db_fixtures/third_party_auth.json | 5 +- .../student_account/test/test_views.py | 26 +++++- lms/djangoapps/student_account/views.py | 3 +- lms/envs/bok_choy.env.json | 2 +- lms/envs/bok_choy.py | 1 - lms/static/sass/base/_variables.scss | 2 + lms/static/sass/multicourse/_account.scss | 3 +- lms/static/sass/views/_login-register.scss | 33 +++---- lms/templates/login.html | 9 +- lms/templates/register-form.html | 9 +- .../student_account/hinted_login.underscore | 6 +- .../student_account/login.underscore | 6 +- .../student_account/register.underscore | 6 +- lms/urls.py | 1 + screenshots/baseline/hinted-login.png | Bin 0 -> 14626 bytes screenshots/baseline/login-providers.png | Bin 0 -> 5299 bytes screenshots/baseline/register-providers.png | Bin 0 -> 5889 bytes test_root/uploads/.gitignore | 2 + test_root/uploads/test-icon.png | Bin 0 -> 208 bytes .../lms/templates/register-form.html | 9 +- 29 files changed, 302 insertions(+), 41 deletions(-) create mode 100644 common/djangoapps/third_party_auth/migrations/0002_schema__provider_icon_image.py create mode 100644 common/djangoapps/third_party_auth/tests/test_admin.py create mode 100644 screenshots/baseline/hinted-login.png create mode 100644 screenshots/baseline/login-providers.png create mode 100644 screenshots/baseline/register-providers.png create mode 100644 test_root/uploads/test-icon.png diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json index 44cd55ef73..943a80bf1e 100644 --- a/cms/envs/bok_choy.env.json +++ b/cms/envs/bok_choy.env.json @@ -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", diff --git a/common/djangoapps/config_models/admin.py b/common/djangoapps/config_models/admin.py index a0753ff33c..3718ad3131 100644 --- a/common/djangoapps/config_models/admin.py +++ b/common/djangoapps/config_models/admin.py @@ -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 diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index 02cc865325..3491be6eee 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -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', ) diff --git a/common/djangoapps/third_party_auth/migrations/0002_schema__provider_icon_image.py b/common/djangoapps/third_party_auth/migrations/0002_schema__provider_icon_image.py new file mode 100644 index 0000000000..073cddf69c --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0002_schema__provider_icon_image.py @@ -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), + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index b699cbe25e..5200a92b71 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -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( diff --git a/common/djangoapps/third_party_auth/tests/test_admin.py b/common/djangoapps/third_party_auth/tests/test_admin.py new file mode 100644 index 0000000000..9ae56b5a86 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_admin.py @@ -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', ''), + ) + + # 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']) diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index 36ac1c698c..6648d09b55 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -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() @@ -124,7 +136,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) diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss index 09f2e1b982..cf781c3b50 100644 --- a/common/static/sass/_mixins.scss +++ b/common/static/sass/_mixins.scss @@ -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; + } +} diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 1999c385ae..2ab32a4640 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -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: diff --git a/common/test/db_fixtures/third_party_auth.json b/common/test/db_fixtures/third_party_auth.json index 3042ebbb66..9b4fad7bff 100644 --- a/common/test/db_fixtures/third_party_auth.json +++ b/common/test/db_fixtures/third_party_auth.json @@ -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": "", diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 585233cb93..59dad97124 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -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', ''), + ) @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) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 3db78369e2..427c717d40 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -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, diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index a745c4c615..b73ed4f7c8 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -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", diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index d461d4bbf3..366fb3dd4c 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -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 diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 6e68f74e90..f68c6b1e36 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -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; diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index 1eacb688e8..600006c1e4 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -524,8 +524,9 @@ margin-right: ($baseline/2); .icon { - color: inherit; + @extend %sso-icon; @include margin-right($baseline/2); + color: inherit; } &:last-child { diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index e9b5cde41d..a7d95993e6 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -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 { diff --git a/lms/templates/login.html b/lms/templates/login.html index 439cfab8da..26338f5571 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -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). - + % endfor diff --git a/lms/templates/register-form.html b/lms/templates/register-form.html index c8688cbc98..c9470bf4c3 100644 --- a/lms/templates/register-form.html +++ b/lms/templates/register-form.html @@ -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). - + % endfor diff --git a/lms/templates/student_account/hinted_login.underscore b/lms/templates/student_account/hinted_login.underscore index d1cb0d8379..6b7dda7605 100644 --- a/lms/templates/student_account/hinted_login.underscore +++ b/lms/templates/student_account/hinted_login.underscore @@ -8,7 +8,11 @@

<%- _.sprintf( gettext("Would you like to sign in using your %(providerName)s credentials?"), { providerName: hintedProvider.name } ) %>

diff --git a/lms/templates/student_account/login.underscore b/lms/templates/student_account/login.underscore index 5ee369923a..c28ead2c27 100644 --- a/lms/templates/student_account/login.underscore +++ b/lms/templates/student_account/login.underscore @@ -61,7 +61,11 @@ <% _.each( context.providers, function( provider ) { if ( provider.loginUrl ) { %> diff --git a/lms/templates/student_account/register.underscore b/lms/templates/student_account/register.underscore index ddd409dbda..bbcb7f12cd 100644 --- a/lms/templates/student_account/register.underscore +++ b/lms/templates/student_account/register.underscore @@ -30,7 +30,11 @@ _.each( context.providers, function( provider) { if ( provider.registerUrl ) { %> diff --git a/lms/urls.py b/lms/urls.py index 18c5cb80c4..c8b96c99c9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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'] diff --git a/screenshots/baseline/hinted-login.png b/screenshots/baseline/hinted-login.png new file mode 100644 index 0000000000000000000000000000000000000000..fe581ce31af24b6e1805aabe99a18944166342bb GIT binary patch literal 14626 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 A*8l(j literal 0 HcmV?d00001 diff --git a/screenshots/baseline/login-providers.png b/screenshots/baseline/login-providers.png new file mode 100644 index 0000000000000000000000000000000000000000..70c19ca2262e8ccf0768480bccd243e1c174bc75 GIT binary patch literal 5299 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 literal 0 HcmV?d00001 diff --git a/test_root/uploads/.gitignore b/test_root/uploads/.gitignore index 409deae7b2..8e2c34b24a 100644 --- a/test_root/uploads/.gitignore +++ b/test_root/uploads/.gitignore @@ -2,3 +2,5 @@ *.jpg *.png *.txt +*.svg +!test_icon.png diff --git a/test_root/uploads/test-icon.png b/test_root/uploads/test-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1c8e813fdc51eea71e479901f623b463a4fc875 GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^DL{OJ14uAfhfB)>DW)WEcNd2L?fqx=19_YU9+AZi z4BWyX%*Zfnjs#GUy~NYkmHh#;IJ>skb${_('Sign up with {provider_name}').format(provider_name=enabled.name)} + % endfor From c5072d63ac0d993cd046b52cb92546ee08363199 Mon Sep 17 00:00:00 2001 From: Omar Khan Date: Fri, 1 Apr 2016 08:58:00 +0700 Subject: [PATCH 3/8] Add azuread SSO button style --- lms/static/sass/views/_login-register.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index a7d95993e6..ad7431c247 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -460,6 +460,25 @@ } } + &.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 { From 60f4f1ac774d5ea251cec003cc4d0c593665fcf4 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 29 Mar 2016 14:16:03 -0700 Subject: [PATCH 4/8] Enable Azure AD third party auth provider by default --- .../tests/specs/test_azuread.py | 46 +++++++++++++++++++ .../third_party_auth/tests/testutil.py | 10 ++++ lms/envs/aws.py | 1 + lms/envs/test.py | 1 + 4 files changed, 58 insertions(+) create mode 100644 common/djangoapps/third_party_auth/tests/specs/test_azuread.py diff --git a/common/djangoapps/third_party_auth/tests/specs/test_azuread.py b/common/djangoapps/third_party_auth/tests/specs/test_azuread.py new file mode 100644 index 0000000000..680983250a --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/specs/test_azuread.py @@ -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') diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index 6648d09b55..fa9ef60bf1 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -124,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 """ diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 8995a09385..5096767d52 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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) diff --git a/lms/envs/test.py b/lms/envs/test.py index 505d32958f..ba43f30574 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -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', From a254d7542ae8f396c726fae484fb62df482fe53e Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Mon, 21 Mar 2016 16:18:02 -0400 Subject: [PATCH 5/8] make sure email from addresses don't exceed 320 characters (TNL-4264) clean up and fix quality violations since get_course can never return None, we can remove these lines --- lms/djangoapps/bulk_email/tasks.py | 36 +++-- lms/djangoapps/bulk_email/tests/test_email.py | 123 ++++++++++++++---- 2 files changed, 126 insertions(+), 33 deletions(-) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index b4308ef2d0..1c894be49c 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -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 diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index fe95b073b3..d1a32e641d 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -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 = '
' + # 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 = '
' - # 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): From 1799634c2e21bb2885984b7240d132da245fe9a4 Mon Sep 17 00:00:00 2001 From: Awais Jibran Date: Tue, 29 Mar 2016 13:56:19 +0500 Subject: [PATCH 6/8] Fix certificate exception view. ECOM-3910 --- .../lms/test_lms_instructor_dashboard.py | 34 +++++++++++++++++-- .../certificate_whitelist_factory.js | 2 +- .../instructor_dashboard_2/certificates.html | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py index dd79a53310..df4e0a2455 100644 --- a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py +++ b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py @@ -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'), + ('Notes', 'Notes'), + ) + @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): diff --git a/lms/static/js/certificates/factories/certificate_whitelist_factory.js b/lms/static/js/certificates/factories/certificate_whitelist_factory.js index 267c63a9f2..b008e85316 100644 --- a/lms/static/js/certificates/factories/certificate_whitelist_factory.js +++ b/lms/static/js/certificates/factories/certificate_whitelist_factory.js @@ -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, diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html index 1c599355bb..8b4f4a3fc5 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificates.html +++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html @@ -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 module_name="js/certificates/factories/certificate_invalidation_factory" class_name="CertificateInvalidationFactory"> From 03925474b9cab340a878a681c0bf9de7919c2c47 Mon Sep 17 00:00:00 2001 From: Awais Jibran Date: Wed, 30 Mar 2016 20:12:14 +0500 Subject: [PATCH 7/8] Verification Upgrade Deadline panel on course home page should not show if user is already verified --- common/djangoapps/course_modes/models.py | 2 +- lms/djangoapps/courseware/date_summary.py | 21 ++++++++ .../courseware/tests/test_date_summary.py | 54 +++++++++++++++---- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 1d1deb8787..a7409bad23 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -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) diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 4d9c1b9dc7..e2b998b55b 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -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: diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py index 8682749c70..69683d7602 100644 --- a/lms/djangoapps/courseware/tests/test_date_summary.py +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -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): From 76057fc4d27898fe9140f330f4d71dca9d178fd7 Mon Sep 17 00:00:00 2001 From: Syed Hassan Raza Date: Fri, 1 Apr 2016 15:44:55 +0500 Subject: [PATCH 8/8] semantic task_input for certificate generation ECOM-3505 --- lms/djangoapps/certificates/views/support.py | 7 +- .../instructor/tests/test_certificates.py | 40 ++- lms/djangoapps/instructor/views/api.py | 41 +-- lms/djangoapps/instructor_task/api.py | 52 ++- .../instructor_task/tasks_helper.py | 44 ++- .../instructor_task/tests/test_api.py | 13 + .../tests/test_tasks_helper.py | 316 ++++++++++++------ .../certificate-white-list.underscore | 3 +- 8 files changed, 346 insertions(+), 170 deletions(-) diff --git a/lms/djangoapps/certificates/views/support.py b/lms/djangoapps/certificates/views/support.py index 65d92adacf..89af213b74 100644 --- a/lms/djangoapps/certificates/views/support.py +++ b/lms/djangoapps/certificates/views/support.py @@ -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) diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index 5367e871c7..b8e8e0317c 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -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".' ) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 9cd7fb2e71..cca2c8ca1c 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -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): diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 1f7b50847f..10939226f4 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -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 diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index a4f50953ad..bd32135472 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -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) diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 5dcd09769a..2e1b3648dd 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -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. diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 72a354652d..1d05a83dec 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -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): """ diff --git a/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore index 581c3ecfd5..0e97cc3af9 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore +++ b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore @@ -2,7 +2,8 @@