diff --git a/biz/djangoapps/ga_contract_operation/personalinfo.py b/biz/djangoapps/ga_contract_operation/personalinfo.py index b1598d88aa8b..f0867abbd22f 100644 --- a/biz/djangoapps/ga_contract_operation/personalinfo.py +++ b/biz/djangoapps/ga_contract_operation/personalinfo.py @@ -61,20 +61,6 @@ def check_enrollment(self, user): return True - def disable_user_info(self, user): - """ - Override masked value to user information. - - Note: We can `NEVER` restore the masked value. - """ - # To force opt-out state since global course is to be registered on a daily batch. - mask_utils.optout_receiving_global_course_emails(user, self.global_course_ids) - mask_utils.disconnect_third_party_auth(user) - mask_utils.mask_name(user) - mask_utils.mask_email(user) - mask_utils.mask_login_code(user) - mask_utils.delete_certificates(user) - def disable_additional_info(self, contract_register): """ Override masked value to additional information. @@ -202,7 +188,7 @@ def perform_delegate_personalinfo_mask(entry_id, task_input, action_name): if not executor.check_enrollment(user): task_progress.skip() continue - executor.disable_user_info(user) + mask_utils.disable_user_info(user) target.complete() except: # If an exception occur, logging it and to continue processing next target. diff --git a/biz/djangoapps/ga_invitation/middleware.py b/biz/djangoapps/ga_invitation/middleware.py index c054639ce1e5..88f893babadc 100644 --- a/biz/djangoapps/ga_invitation/middleware.py +++ b/biz/djangoapps/ga_invitation/middleware.py @@ -11,8 +11,8 @@ from biz.djangoapps.ga_contract.models import Contract, ContractDetail from biz.djangoapps.ga_invitation.models import ContractRegister from biz.djangoapps.util.access_utils import has_staff_access -from courseware.access import has_access, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW -from student.roles import CourseBetaTesterRole +from courseware.access import has_access, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW +from student.roles import CourseBetaTesterRole, GaCourseScorerRole SpocStatus = namedtuple('SpocStatus', 'is_spoc_course has_spoc_access') @@ -42,6 +42,8 @@ def _has_spoc_access(user, contract_ids): has_staff_access(user, course_id) or _has_spoc_access(user, _spoc_contract_ids) or has_access(user, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW, 'global') or + has_access(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global') or + GaCourseScorerRole(course_id).has_user(user) or ( Contract.objects.enabled().filter(pk__in=_spoc_contract_ids).exists() and CourseBetaTesterRole(course_id).has_user(user) diff --git a/biz/djangoapps/ga_invitation/models.py b/biz/djangoapps/ga_invitation/models.py index 816998aa9b75..3c95abda7862 100644 --- a/biz/djangoapps/ga_invitation/models.py +++ b/biz/djangoapps/ga_invitation/models.py @@ -77,6 +77,10 @@ def get_value_by_display_name(cls, user, contract, display_name): def find_by_user_and_contract(cls, user, contract): return cls.objects.filter(user=user, contract=contract) + @classmethod + def find_by_user(cls, user): + return cls.objects.filter(user=user) + @classmethod def find_by_contract(cls, contract): return cls.objects.filter(contract=contract).order_by('id') diff --git a/biz/djangoapps/ga_invitation/tests/test_middleware.py b/biz/djangoapps/ga_invitation/tests/test_middleware.py index ff225c7a89db..7f3421e99452 100644 --- a/biz/djangoapps/ga_invitation/tests/test_middleware.py +++ b/biz/djangoapps/ga_invitation/tests/test_middleware.py @@ -14,7 +14,7 @@ from courseware.access_utils import ACCESS_DENIED, ACCESS_GRANTED from courseware.tests.helpers import LoginEnrollmentTestCase from opaque_keys.edx.keys import CourseKey -from student.roles import CourseBetaTesterRole, CourseStaffRole, GaOldCourseViewerStaffRole, GlobalStaff +from student.roles import CourseBetaTesterRole, CourseStaffRole, GaCourseScorerRole, GaGlobalCourseCreatorRole, GaOldCourseViewerStaffRole, GlobalStaff from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -206,7 +206,13 @@ def test_process_request_spoc_course_with_betatester(self): # has_spoc_access is True if user is not enroll betatesters self.assertEqual((True, True), self.request.spoc_status) - @ddt.data((GlobalStaff, ACCESS_GRANTED), (GaOldCourseViewerStaffRole, ACCESS_GRANTED), (CourseBetaTesterRole, False)) + @ddt.data( + (GlobalStaff, ACCESS_GRANTED), + (GaOldCourseViewerStaffRole, ACCESS_GRANTED), + (GaGlobalCourseCreatorRole, ACCESS_GRANTED), + (GaCourseScorerRole, True), + (CourseBetaTesterRole, False), + ) @ddt.unpack def test_process_request_spoc_disabled_and_specificate_role(self, data_role, expected_has_access): course_id = CourseKey.from_string('course-v1:org+course+run') @@ -220,7 +226,7 @@ def test_process_request_spoc_disabled_and_specificate_role(self, data_role, exp ) # Add Role - if data_role is CourseBetaTesterRole: + if data_role is CourseBetaTesterRole or data_role is GaCourseScorerRole: data_role(course_id).add_users(self.user) else: data_role().add_users(self.user) @@ -233,7 +239,7 @@ def test_process_request_spoc_disabled_and_specificate_role(self, data_role, exp SpocStatusMiddleware().process_request(self.request) # If contract is disabled, has_access is False regardless of the status of ContractRegister - # But global staff and ga_old_course_viewer is True + # But global staff, ga_old_course_viewer and ga_global_course_creator is True self.assertEqual((True, expected_has_access), self.request.spoc_status) @ddt.data( @@ -439,6 +445,43 @@ def test_spoc_course_with_course_staff(self): resp = self.client.get(url) self.assertEqual(resp.status_code, 200) + def test_spoc_course_with_ga_global_course_creator(self): + self.setup_user() + + # Make user to ga_global_course_creator + GaGlobalCourseCreatorRole().add_users(User.objects.get(pk=self.user.id)) + + # Create Contract but not SPOC + contract_detail = self._create_contract( + user=self.user, + course_id=self.course.id, + contract_type=CONTRACT_TYPE_PF[0] + ) + # Not Create ContractRegister + + url = reverse('about_course', args=[self.course.id.to_deprecated_string()]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + + def test_spoc_course_with_ga_course_scorer(self): + self.setup_user() + + # Make user to ga_course_scorer + GaCourseScorerRole(self.course.id).add_users(User.objects.get(pk=self.user.id)) + + # Create Contract but not SPOC + contract_detail = self._create_contract( + user=self.user, + course_id=self.course.id, + contract_type=CONTRACT_TYPE_PF[0] + ) + # Not Create ContractRegister + + url = reverse('about_course', args=[self.course.id.to_deprecated_string()]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + + @ddt.ddt class CourseInfoTest(SpocStatusTestBase): @@ -619,3 +662,39 @@ def test_spoc_course_with_course_staff(self): url = reverse('info', args=[self.course.id.to_deprecated_string()]) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) + + def test_spoc_course_with_ga_global_course_creator(self): + self.setup_user() + + # Make user to ga_global_course_creator + GaGlobalCourseCreatorRole().add_users(User.objects.get(pk=self.user.id)) + + # Create Contract but not SPOC + contract_detail = self._create_contract( + user=self.user, + course_id=self.course.id, + contract_type=CONTRACT_TYPE_PF[0] + ) + # Not Create ContractRegister + + url = reverse('info', args=[self.course.id.to_deprecated_string()]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + + def test_spoc_course_with_ga_course_scorer(self): + self.setup_user() + + # Make user to ga_course_scorer + GaCourseScorerRole(self.course.id).add_users(User.objects.get(pk=self.user.id)) + + # Create Contract but not SPOC + contract_detail = self._create_contract( + user=self.user, + course_id=self.course.id, + contract_type=CONTRACT_TYPE_PF[0] + ) + # Not Create ContractRegister + + url = reverse('info', args=[self.course.id.to_deprecated_string()]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) diff --git a/biz/djangoapps/ga_student/management/commands/mask_resigned_user.py b/biz/djangoapps/ga_student/management/commands/mask_resigned_user.py new file mode 100644 index 000000000000..f59f9bba1060 --- /dev/null +++ b/biz/djangoapps/ga_student/management/commands/mask_resigned_user.py @@ -0,0 +1,74 @@ +""" +Management command to mask users who already resigned. +""" + +import logging +from optparse import make_option + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand +from django.db import transaction + +from biz.djangoapps.util import mask_utils, datetime_utils +from openedx.core.djangoapps.course_global.models import CourseGlobalSetting +from student.models import Registration, UserStanding + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Mask users who already resigned. + """ + help = """ + Usage: python manage.py lms --settings=aws mask_resigned_user [--debug] + """ + + option_list = BaseCommand.option_list + ( + make_option('--debug', + default=False, + action='store_true', + help='Use debug log'), + ) + + def handle(self, *args, **options): + debug = options.get('debug') + if debug: + stream = logging.StreamHandler(self.stdout) + log.addHandler(stream) + log.setLevel(logging.DEBUG) + log.info(u"Command mask_resigned_user started at {}.".format(datetime_utils.timezone_now())) + + mask_users = User.objects.filter( + email__contains='@', # Do not mask users who have already masked by biz (#1908) + standing__account_status=UserStanding.ACCOUNT_DISABLED + ).order_by('username') + + # debug output for comparison with sql output + if debug: + log.debug(u"--------------debug target output (start)--------------") + log.debug('userid,username,email') + for user in mask_users: + log.debug(','.join([str(user.id), user.username, user.email])) + log.debug(u"--------------debug target output (finished)--------------") + else: + failed_users = [] + + for user in mask_users: + log.info(u"Masked start user_id, user_name : {}, {}.".format(user.id, user.username)) + try: + with transaction.atomic(): + mask_utils.disable_all_additional_info(user) + mask_utils.disable_user_info(user) + Registration.objects.get(user=user).update_masked() + except: + log.exception(u"Masked failed user_id, user_name : {}, {}.".format(user.id, user.username)) + failed_users.append(str(user.id) + '-' + user.username) + else: + log.info(u"Masked success user_id, user_name : {}, {}.".format(user.id, user.username)) + + if failed_users: + log.error(u"Masked failed users : {}".format(failed_users)) + + log.info(u"Command mask_user finished at {}.".format(datetime_utils.timezone_now())) diff --git a/biz/djangoapps/ga_student/management/commands/tests/test_mask_resigned_user.py b/biz/djangoapps/ga_student/management/commands/tests/test_mask_resigned_user.py new file mode 100644 index 000000000000..b3e8496e2aca --- /dev/null +++ b/biz/djangoapps/ga_student/management/commands/tests/test_mask_resigned_user.py @@ -0,0 +1,139 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". +Replace this with more appropriate tests for your application. +""" +import uuid + +from mock import patch + +from django.contrib.auth.models import User +from django.core.management import call_command + +from biz.djangoapps.util.tests.testcase import BizTestBase +from student.models import Registration, UserStanding +from student.tests.factories import UserFactory, RegistrationFactory, UserStandingFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class MaskResignedUserTest(BizTestBase, ModuleStoreTestCase): + def setUp(self): + super(MaskResignedUserTest, self).setUp() + + self.random_value = 'test1' + + patcher = patch('biz.djangoapps.util.mask_utils.get_random_string') + self.mock_get_random_string = patcher.start() + self.mock_get_random_string.return_value = self.random_value + self.addCleanup(patcher.stop) + + def test_mask_resigned_user(self): + # create user + resigned_user = UserFactory.create( + email='resigned@example.com', + is_active=True + ) + enrollment_user = UserFactory.create( + email='enrollment@example.com', + is_active=True + ) + RegistrationFactory.create(user=resigned_user, activation_key=uuid.uuid4().hex, masked=False) + RegistrationFactory.create(user=enrollment_user, activation_key=uuid.uuid4().hex, masked=False) + + # resigned + UserStandingFactory.create( + user=resigned_user, + account_status=UserStanding.ACCOUNT_DISABLED, + changed_by=resigned_user, + ) + + call_command('mask_resigned_user', debug=False) + + masked_user = User.objects.get(pk=resigned_user.pk) + self.assertTrue(masked_user.registration.masked) + self.assertFalse('@' in masked_user.email) + unmasked_user = User.objects.get(pk=enrollment_user.pk) + self.assertFalse(unmasked_user.registration.masked) + self.assertEquals('enrollment@example.com', unmasked_user.email) + + @patch('biz.djangoapps.ga_student.management.commands.mask_resigned_user.log.error') + @patch('biz.djangoapps.ga_student.management.commands.mask_resigned_user.log.exception') + @patch('biz.djangoapps.ga_student.management.commands.mask_resigned_user.log.info') + def test_mask_resigned_user_failed(self, mock_log_info, mock_log_exception, mock_log_error): + # create user + error1_user = UserFactory.create( + email='error1@example.com', + username='b_error', + is_active=True + ) + error2_user = UserFactory.create( + email='error2@example.com', + username='d_error', + is_active=True + ) + resigned1_user = UserFactory.create( + email='resigned1@example.com', + username='a_resigned1', + is_active=True + ) + resigned2_user = UserFactory.create( + email='resigned2@example.com', + username='c_resigned2', + is_active=True + ) + target_users = [error1_user, error2_user, resigned1_user, resigned2_user] + for user in target_users: + RegistrationFactory.create(user=user, activation_key=uuid.uuid4().hex, masked=False) + # resigned + UserStandingFactory.create( + user=user, + account_status=UserStanding.ACCOUNT_DISABLED, + changed_by=user, + ) + + # raise Exception + self.mock_get_random_string.side_effect = [self.random_value, Exception, self.random_value, Exception] + + call_command('mask_resigned_user', debug=False) + + self.assertEqual(8, mock_log_info.call_count) + self.assertEqual(2, mock_log_exception.call_count) + self.assertEqual(1, mock_log_error.call_count) + + # masked user + user = resigned1_user + mock_log_info.assert_any_call(u"Masked start user_id, user_name : {}, {}.".format(user.id, user.username)) + mock_log_info.assert_any_call(u"Masked success user_id, user_name : {}, {}.".format(user.id, user.username)) + masked_user = User.objects.get(pk=user.pk) + self.assertTrue(masked_user.registration.masked) + self.assertFalse('@' in masked_user.email) + + # masked user but occured error + user = error1_user + mock_log_info.assert_any_call(u"Masked start user_id, user_name : {}, {}.".format(user.id, user.username)) + mock_log_exception.assert_any_call(u"Masked failed user_id, user_name : {}, {}.".format(user.id, user.username)) + unmasked_user = User.objects.get(pk=user.pk) + self.assertFalse(unmasked_user.registration.masked) + self.assertEquals('error1@example.com', unmasked_user.email) + + # masked user + user = resigned2_user + mock_log_info.assert_any_call(u"Masked start user_id, user_name : {}, {}.".format(user.id, user.username)) + mock_log_info.assert_any_call(u"Masked success user_id, user_name : {}, {}.".format(user.id, user.username)) + masked_user = User.objects.get(pk=user.pk) + self.assertTrue(masked_user.registration.masked) + self.assertFalse('@' in masked_user.email) + + # masked user but occured error + user = error2_user + mock_log_info.assert_any_call(u"Masked start user_id, user_name : {}, {}.".format(user.id, user.username)) + mock_log_exception.assert_any_call(u"Masked failed user_id, user_name : {}, {}.".format(user.id, user.username)) + unmasked_user = User.objects.get(pk=error2_user.pk) + self.assertFalse(unmasked_user.registration.masked) + self.assertEquals('error2@example.com', unmasked_user.email) + + failed_users = [ + str(error1_user.id) + u'-' + error1_user.username, + str(error2_user.id) + u'-' + error2_user.username, + ] + mock_log_error.assert_called_once_with(u"Masked failed users : {}".format(failed_users)) diff --git a/biz/djangoapps/util/mask_utils.py b/biz/djangoapps/util/mask_utils.py index d6949ca82e01..bc3c76bd6ffe 100644 --- a/biz/djangoapps/util/mask_utils.py +++ b/biz/djangoapps/util/mask_utils.py @@ -10,15 +10,42 @@ from django.utils.crypto import get_random_string from social.apps.django_app import utils as social_utils +from biz.djangoapps.ga_invitation.models import AdditionalInfoSetting from bulk_email.models import Optout from certificates.models import CertificateStatuses, GeneratedCertificate from student.models import CourseEnrollmentAllowed, ManualEnrollmentAudit, PendingEmailChange from third_party_auth import pipeline +from openedx.core.djangoapps.course_global.models import CourseGlobalSetting from pdfgen.certificate import CertificatePDF +from ga_shoppingcart.models import PersonalInfo + log = logging.getLogger(__name__) +def disable_user_info(user): + """ + Override masked value to user information. + + Note: We can `NEVER` restore the masked value. + """ + # To force opt-out state since global course is to be registered on a daily batch. + global_course_ids = set(CourseGlobalSetting.all_course_id()) + optout_receiving_global_course_emails(user, global_course_ids) + disconnect_third_party_auth(user) + mask_name(user) + mask_login_code(user) + delete_certificates(user) + mask_shoppingcart_personalinfo(user) + mask_email(user) + + +def disable_all_additional_info(user): + for additional_setting in AdditionalInfoSetting.find_by_user(user): + additional_setting.value = hash(additional_setting.value) + additional_setting.save() + + def optout_receiving_global_course_emails(user, global_course_ids): for global_course_id in global_course_ids: optout, _ = Optout.objects.get_or_create(user=user, course_id=global_course_id) @@ -50,7 +77,7 @@ def mask_email(user): for cea in CourseEnrollmentAllowed.objects.filter(email=user.email): cea.email = hashed_email cea.save() - for mea in ManualEnrollmentAudit.objects.filter(enrolled_by_id=user.id): + for mea in ManualEnrollmentAudit.objects.filter(enrolled_email=user.email): mea.enrolled_email = hashed_email mea.save() for pec in PendingEmailChange.objects.filter(user_id=user.id): @@ -63,6 +90,22 @@ def mask_email(user): user.profile.save() +def mask_shoppingcart_personalinfo(user): + for pis in PersonalInfo.objects.filter(user=user): + pis.full_name = hash(pis.full_name) if pis.full_name is not None else None + pis.kana = hash(pis.kana) if pis.kana is not None else None + pis.postal_code = None + pis.address_line_1 = hash(pis.address_line_1) if pis.address_line_1 is not None else None + pis.address_line_2 = hash(pis.address_line_2) if pis.address_line_2 is not None else None + pis.phone_number = None + pis.free_entry_field_1 = hash(pis.free_entry_field_1) if pis.free_entry_field_1 is not None else None + pis.free_entry_field_2 = hash(pis.free_entry_field_2) if pis.free_entry_field_2 is not None else None + pis.free_entry_field_3 = hash(pis.free_entry_field_3) if pis.free_entry_field_3 is not None else None + pis.free_entry_field_4 = hash(pis.free_entry_field_4) if pis.free_entry_field_4 is not None else None + pis.free_entry_field_5 = hash(pis.free_entry_field_5) if pis.free_entry_field_5 is not None else None + pis.save() + + def disconnect_third_party_auth(user): for state in pipeline.get_provider_user_states(user): strategy = social_utils.load_strategy() diff --git a/biz/djangoapps/util/tests/test_mask_utils.py b/biz/djangoapps/util/tests/test_mask_utils.py index 75150e2162ad..811b7f9a79c0 100644 --- a/biz/djangoapps/util/tests/test_mask_utils.py +++ b/biz/djangoapps/util/tests/test_mask_utils.py @@ -12,9 +12,16 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from biz.djangoapps.ga_invitation.models import AdditionalInfoSetting +from biz.djangoapps.ga_invitation.tests.factories import AdditionalInfoSettingFactory +from biz.djangoapps.ga_invitation.tests.test_views import BizContractTestBase + +from ga_shoppingcart.models import PersonalInfo +from ga_shoppingcart.tests.factories import PersonalInfoFactory, PersonalInfoSettingFactory +from ga_shoppingcart.tests.utils import get_order_from_advanced_course @ddt.ddt -class MaskUtilsTest(BizTestBase, ModuleStoreTestCase): +class MaskUtilsTest(BizContractTestBase, ModuleStoreTestCase): """Test for mask utilities""" def setUp(self): @@ -63,3 +70,89 @@ def test_delete_certificates_error(self, status, mock_delete, mock_log_error): mask_utils.delete_certificates(self.user) self.assertEqual(e.exception.message, error_message) mock_log_error.assert_called_once_with('Failed to delete certificate. user={user_id}, course_id={course_id}'.format(user_id=self.user.id, course_id=self.course.id)) + + def test_disable_all_additional_info(self): + user = UserFactory.create() + + for additional_info in self.contract.additional_info.all(): + AdditionalInfoSettingFactory.create( + user=user, + contract=self.contract, + display_name=additional_info.display_name, + value='value_of_{}_{}'.format(additional_info.display_name, self.contract.id) + ) + for additional_info in self.contract_mooc.additional_info.all(): + AdditionalInfoSettingFactory.create( + user=user, + contract=self.contract_mooc, + display_name=additional_info.display_name, + value='value_of_{}_{}'.format(additional_info.display_name, self.contract_mooc.id) + ) + + for additional_setting in AdditionalInfoSetting.find_by_user_and_contract(user, self.contract): + self.assertEqual(additional_setting.value, + 'value_of_{}_{}'.format(additional_setting.display_name, self.contract.id)) + for additional_setting in AdditionalInfoSetting.find_by_user_and_contract(user, self.contract_mooc): + self.assertEqual(additional_setting.value, + 'value_of_{}_{}'.format(additional_setting.display_name, self.contract_mooc.id)) + + mask_utils.disable_all_additional_info(user) + + for additional_setting in AdditionalInfoSetting.find_by_user_and_contract(user, self.contract): + self.assertEqual( + additional_setting.value, + mask_utils.hash('value_of_{}_{}'.format(additional_setting.display_name, self.contract.id))) + for additional_setting in AdditionalInfoSetting.find_by_user_and_contract(user, self.contract_mooc): + self.assertEqual( + additional_setting.value, + mask_utils.hash('value_of_{}_{}'.format(additional_setting.display_name, self.contract_mooc.id))) + + def test_mask_shoppingcart_personalinfo(self): + user = UserFactory.create() + + course_for_advanced = CourseFactory.create( + metadata={ + 'is_f2f_course': True, + 'is_f2f_course_sell': True, + } + ) + order, advanced_course = get_order_from_advanced_course(course_for_advanced, user) + personal_info_setting = PersonalInfoSettingFactory.create(**dict( + advanced_course=advanced_course, + free_entry_field_1_title='aaa', + free_entry_field_2_title='bbb', + free_entry_field_3_title='ccc', + free_entry_field_4_title='ddd', + free_entry_field_5_title='eee' + )) + personal_info = PersonalInfoFactory.create(**dict( + user=user, + full_name='name', + kana='kana', + postal_code='1111111', + address_line_1='address1', + address_line_2='address2', + phone_number='09000000000', + free_entry_field_1='free1', + free_entry_field_2='free2', + free_entry_field_3='free3', + free_entry_field_4='free4', + free_entry_field_5='free5', + order_id=order.id, + choice=personal_info_setting + )) + + mask_utils.mask_shoppingcart_personalinfo(user) + + for pis in PersonalInfo.objects.filter(user=user): + self.assertEqual(pis.full_name, mask_utils.hash(personal_info.full_name)) + self.assertEqual(pis.kana, mask_utils.hash(personal_info.kana)) + self.assertEqual(pis.postal_code, None) + self.assertEqual(pis.address_line_1, mask_utils.hash(personal_info.address_line_1)) + self.assertEqual(pis.address_line_2, mask_utils.hash(personal_info.address_line_2)) + self.assertEqual(pis.phone_number, None) + self.assertEqual(pis.free_entry_field_1, mask_utils.hash(personal_info.free_entry_field_1)) + self.assertEqual(pis.free_entry_field_2, mask_utils.hash(personal_info.free_entry_field_2)) + self.assertEqual(pis.free_entry_field_3, mask_utils.hash(personal_info.free_entry_field_3)) + self.assertEqual(pis.free_entry_field_4, mask_utils.hash(personal_info.free_entry_field_4)) + self.assertEqual(pis.free_entry_field_5, mask_utils.hash(personal_info.free_entry_field_5)) diff --git a/biz/templates/ga_course_operation/survey.html b/biz/templates/ga_course_operation/survey.html index 3c82b74a90c5..d8f019a43bc1 100644 --- a/biz/templates/ga_course_operation/survey.html +++ b/biz/templates/ga_course_operation/survey.html @@ -21,7 +21,7 @@
- ${_("The survey result can be downloaded as a CSV file. When you try to open a CSV file as it is in Excel, because that would garbled in the case that contains the Japanese, once you save the file, please re- open it in a text editor that corresponds to the 'UTF-8'. In addition, data on the date and time in the CSV file all UTC we (the world standard time, from Japan Standard Time minus 9 hours) are listed in the.")} + ${_("The survey result can be downloaded as a CSV file.
When you try to open a CSV file as it is in Excel, because that would garbled in the case that contains the Japanese, once you save the file, please re-open it in a text editor that corresponds to the 'UTF-8'.")}
diff --git a/cms/djangoapps/contentstore/tests/test_clone_course.py b/cms/djangoapps/contentstore/tests/test_clone_course.py index a0c9428dd08f..a7e4a3a2118c 100644 --- a/cms/djangoapps/contentstore/tests/test_clone_course.py +++ b/cms/djangoapps/contentstore/tests/test_clone_course.py @@ -6,7 +6,7 @@ from opaque_keys.edx.locator import CourseLocator from xmodule.modulestore import ModuleStoreEnum, EdxJSONEncoder -from contentstore.tests.utils import CourseTestCase +from contentstore.tests.utils import CourseTestCase, switch_ga_global_course_creator from contentstore.tasks import rerun_course from student.auth import has_course_author_access from course_action_state.models import CourseRerunState @@ -144,3 +144,12 @@ def test_rerun_course(self): course_key=split_course4_id, state=CourseRerunUIStateManager.State.FAILED ) + + +class CloneCourseTestWithGaGlobalCourseCreator(CloneCourseTest): + """ + Unit tests for cloning a course + """ + def setUp(self): + super(CloneCourseTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 5d1bc5bdc8ed..140e4383213c 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -52,7 +52,7 @@ from student.models import CourseEnrollment from student.roles import CourseCreatorRole, CourseInstructorRole from opaque_keys import InvalidKeyError -from contentstore.tests.utils import get_url +from contentstore.tests.utils import get_url, switch_ga_global_course_creator from course_action_state.models import CourseRerunState, CourseRerunUIStateManager from course_action_state.managers import CourseActionStateItemNotFoundError @@ -609,6 +609,15 @@ def test_export_course_no_xml_attributes(self): ) +class ImportRequiredTestCasesWithGaGlobalCourseCreator(ImportRequiredTestCases): + """ + Tests which legitimately need to import a course + """ + def setUp(self): + super(ImportRequiredTestCasesWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + @ddt.ddt class MiscCourseTests(ContentStoreTestCase): """ @@ -1125,6 +1134,15 @@ def _check_verticals(self, locations): self.assertEqual(resp.status_code, 200) +class MiscCourseTestsWithGaGlobalCourseCreator(MiscCourseTests): + """ + Tests that rely on the toy courses. + """ + def setUp(self): + super(MiscCourseTestsWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + @ddt.ddt class ContentStoreTest(ContentStoreTestCase, XssTestMixin): """ @@ -1750,6 +1768,27 @@ def test_course_handler_with_invalid_course_key_string(self): self.assertEquals(response.status_code, 404) +class ContentStoreTestWithGaGlobalCourseCreator(ContentStoreTest): + """ + Tests for the CMS ContentStore application. + """ + def setUp(self): + super(ContentStoreTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + def test_create_course_no_course_creators_not_staff(self): + """ + Exclude this test case + """ + pass + + def test_create_course_with_course_creation_disabled_not_staff(self): + """ + Exclude this test case + """ + pass + + class MetadataSaveTestCase(ContentStoreTestCase): """Test that metadata is correctly cached and decached.""" @@ -1809,6 +1848,14 @@ def test_metadata_persistence(self): pass +class MetadataSaveTestCaseWithGaGlobalCourseCreator(MetadataSaveTestCase): + """Test that metadata is correctly cached and decached.""" + + def setUp(self): + super(MetadataSaveTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class RerunCourseTest(ContentStoreTestCase): """ Tests for Rerunning a course via the view handler @@ -2059,6 +2106,21 @@ def test_rerun_course_wiki_slug(self): self.assertEquals(destination_course.wiki_slug, destination_wiki_slug) +class RerunCourseTestWithGaGlobalCourseCreator(RerunCourseTest): + """ + Tests for Rerunning a course via the view handler + """ + def setUp(self): + super(RerunCourseTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + def test_rerun_with_permission_denied(self): + """ + Exclude this test case + """ + pass + + class ContentLicenseTest(ContentStoreTestCase): """ Tests around content licenses @@ -2097,6 +2159,15 @@ def test_license_import(self): self.assertEqual(videos[0].license, "all-rights-reserved") +class ContentLicenseTestWithGaGlobalCourseCreator(ContentLicenseTest): + """ + Tests around content licenses + """ + def setUp(self): + super(ContentLicenseTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class EntryPageTestCase(TestCase): """ Tests entry pages that aren't specific to a course. diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index e70fc9a67142..336a31b5fac1 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -12,7 +12,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from student.roles import CourseInstructorRole, CourseStaffRole +from student.roles import CourseInstructorRole, CourseStaffRole, GaGlobalCourseCreatorRole from student.tests.factories import UserFactory from contentstore.tests.utils import AjaxEnabledTestClient, parse_json from datetime import datetime @@ -156,3 +156,17 @@ def test_course_creation_with_org_in_system(self, store): course_orgs = get_course_organizations(new_course_key) self.assertEqual(len(course_orgs), 1) self.assertEqual(course_orgs[0]['short_name'], 'orgX') + + +class TestCourseListingWithGaGlobalCourseCreator(TestCourseListing): + """ + Unit tests for getting the list of courses for a logged in user + """ + def setUp(self): + """ + Add a user and a course + """ + super(TestCourseListingWithGaGlobalCourseCreator, self).setUp() + for role in [CourseInstructorRole, CourseStaffRole]: + role(self.source_course_key).remove_users(self.user) + GaGlobalCourseCreatorRole().add_users(self.user) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 55e530ff0ec2..bcf82c85d84c 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -13,6 +13,7 @@ from django.utils.timezone import UTC from django.test.utils import override_settings +from contentstore.tests.utils import switch_ga_global_course_creator from contentstore.utils import reverse_course_url, reverse_usage_url from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY from models.settings.course_grading import CourseGradingModel @@ -20,6 +21,7 @@ from models.settings.encoder import CourseSettingsEncoder from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.models.course_details import CourseDetails +from student.models import CourseEnrollment from student.roles import CourseInstructorRole, CourseStaffRole from student.tests.factories import UserFactory from xmodule.fields import Date @@ -82,6 +84,12 @@ def test_ooc_encoder(self): self.assertEqual(jsondetails['string'], 'string') +class CourseSettingsEncoderTestWithGaGlobalCourseCreator(CourseSettingsEncoderTest): + def setUp(self): + super(CourseSettingsEncoderTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + @ddt.ddt class CourseDetailsViewTest(CourseTestCase): """ @@ -482,6 +490,16 @@ def test_regular_site_fetch_include_playback_rate(self): self.assertContains(response, "Hide Playback Rate") +class CourseDetailsViewTestWithGaGlobalCourseCreator(CourseDetailsViewTest): + """ + Tests for modifying content on the first course settings page (course dates, overview, etc.). + """ + def setUp(self): + super(CourseDetailsViewTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + SelfPacedConfiguration(enabled=False).save() + + @ddt.ddt class CourseGradingTest(CourseTestCase): """ @@ -693,6 +711,15 @@ def test_set_get_section_grader_ajax(self): self.assertEqual(json.loads(response.content).get('graderType'), u'notgraded') +class CourseGradingTestWithGaGlobalCourseCreator(CourseGradingTest): + """ + Tests for the course settings grading page. + """ + def setUp(self): + super(CourseGradingTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + @ddt.ddt class CourseMetadataEditingTest(CourseTestCase): """ @@ -1100,6 +1127,34 @@ def test_post_settings_with_staff_not_enrolled(self, mock_request): self.assertEqual(response.status_code, 200) +@ddt.ddt +class CourseMetadataEditingTestWithGaGlobalCourseCreator(CourseMetadataEditingTest): + """ + Tests for CourseMetadata. + """ + def setUp(self): + super(CourseMetadataEditingTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + # Note: Test on the course tab needs enrollment + # cf. EnrolledTab.is_enabled + CourseEnrollment.enroll(self.user, self.course.id) + + # Note: It does not pass unless it is overridden + @ddt.data( + [{'type': 'courseware'}, {'type': 'course_info'}, {'type': 'wiki', 'is_hidden': True}], + [{'type': 'courseware', 'name': 'Courses'}, {'type': 'course_info', 'name': 'Info'}], + ) + def test_course_tab_configurations(self, tab_list): + self.course.tabs = tab_list + modulestore().update_item(self.course, self.user.id) + self.client.ajax_post(self.course_setting_url, { + ADVANCED_COMPONENT_POLICY_KEY: {"value": ["notes"]} + }) + course = modulestore().get_course(self.course.id) + tab_list.append(self.notes_tab) + self.assertEqual(tab_list, course.tabs) + + class CourseGraderUpdatesTest(CourseTestCase): """ Test getting, deleting, adding, & updating graders @@ -1164,6 +1219,16 @@ def test_add(self): self.assertEqual(len(self.starting_graders) + 1, len(current_graders)) +class CourseGraderUpdatesTestWithGaGlobalCourseCreator(CourseGraderUpdatesTest): + """ + Test getting, deleting, adding, & updating graders + """ + def setUp(self): + """Compute the url to use in tests""" + super(CourseGraderUpdatesTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class CourseEnrollmentEndFieldTest(CourseTestCase): """ Base class to test the enrollment end fields in the course settings details view in Studio @@ -1275,3 +1340,13 @@ def test_course_details_with_enabled_setting_non_global_staff(self): User is non-global staff. """ self._verify_not_editable(self._get_course_details_response(False)) + + +class CourseEnrollmentEndFieldTestWithGaGlobalCourseCreator(CourseEnrollmentEndFieldTest): + """ + Base class to test the enrollment end fields in the course settings details view in Studio + when using marketing site flag and global vs non-global staff to access the page. + """ + def setUp(self): + super(CourseEnrollmentEndFieldTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py index 512667fa02ac..8889934f9547 100644 --- a/cms/djangoapps/contentstore/tests/test_crud.py +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -1,5 +1,6 @@ import unittest +from contentstore.tests.utils import switch_ga_global_course_creator from xmodule import templates from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -135,3 +136,12 @@ def test_delete_course(self): self.assertIsNone(self.store.get_course(id_locator)) # But can retrieve by guid -- same TODO as above # self.assertIsInstance(self.store.get_item(guid_locator), CourseDescriptor) + + +class TemplateTestsWithGaGlobalCourseCreator(TemplateTests): + """ + Test finding and using the templates (boilerplates) for xblocks. + """ + def setUp(self): + super(TemplateTestsWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py index a4675632a219..64ba0d93541d 100644 --- a/cms/djangoapps/contentstore/tests/test_import.py +++ b/cms/djangoapps/contentstore/tests/test_import.py @@ -10,6 +10,7 @@ import ddt import copy +from contentstore.tests.utils import switch_ga_global_course_creator from openedx.core.djangoapps.content.course_structures.tests import SignalDisconnectTestMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore import ModuleStoreEnum @@ -282,3 +283,13 @@ def test_video_components_present_while_import(self, store): video = module_store.get_item(vertical.children[1]) self.assertEqual(video.display_name, 'default') + + +class ContentStoreImportTestWithGaGlobalCourseCreator(ContentStoreImportTest): + """ + Tests that rely on the toy and test_import_course courses. + NOTE: refactor using CourseFactory so they do not. + """ + def setUp(self): + super(ContentStoreImportTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py index fe4fb57f8570..62e40766af06 100644 --- a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py +++ b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py @@ -2,6 +2,7 @@ Integration tests for importing courses containing pure XBlocks. """ +from contentstore.tests.utils import switch_ga_global_course_creator from xblock.core import XBlock from xblock.fields import String @@ -83,3 +84,9 @@ def _assert_import(self, course_dir, expected_field_val, has_draft=False): self.assertTrue(getattr(draft_xblock, 'is_draft', False)) self.assertTrue(isinstance(draft_xblock, StubXBlock)) self.assertEqual(draft_xblock.test_field, expected_field_val) + + +class XBlockImportTestWithGaGlobalCourseCreator(XBlockImportTest): + def setUp(self): + super(XBlockImportTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index 725d3f04276a..cf8e9ee6accd 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -3,7 +3,7 @@ """ from django.test.client import RequestFactory from django.test.utils import override_settings -from contentstore.tests.utils import AjaxEnabledTestClient, parse_json +from contentstore.tests.utils import AjaxEnabledTestClient, parse_json, switch_ga_global_course_creator from contentstore.utils import reverse_url, reverse_usage_url, reverse_course_url from contentstore.views.item import _duplicate_item from contentstore.views.library import _list_libraries @@ -494,6 +494,12 @@ def test_refresh_fails_for_unknown_library(self): self._refresh_children(lc_block, status_code_expected=400) +class TestLibrariesWithGaGlobalCourseCreator(TestLibraries): + def setUp(self): + super(TestLibrariesWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + @ddt.ddt @patch('django.conf.settings.SEARCH_ENGINE', None) class TestLibraryAccess(SignalDisconnectTestMixin, LibraryTestCase): @@ -1020,6 +1026,15 @@ def test_duplicated_version(self): self.assertEqual(problem2_in_course.display_name, self.original_display_name) +class TestOverridesWithGaGlobalCourseCreator(TestOverrides): + """ + Test that overriding block Scope.settings fields from a library in a specific course works + """ + def setUp(self): + super(TestOverridesWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class TestIncompatibleModuleStore(LibraryTestCase): """ Tests for proper validation errors with an incompatible course modulestore. diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 237d04a756d6..4627a307bb2f 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -12,6 +12,7 @@ from contentstore.utils import reverse_url from student.models import Registration +from student.roles import GaGlobalCourseCreatorRole from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore from xmodule.contentstore.django import contentstore from xmodule.modulestore import ModuleStoreEnum @@ -391,3 +392,9 @@ def get_url(handler_name, key_value, key_name='usage_key_string', kwargs=None): Helper function for getting HTML for a page in Studio and checking that it does not error. """ return reverse_url(handler_name, key_name, key_value, kwargs) + + +def switch_ga_global_course_creator(global_staff): + global_staff.is_staff = False + global_staff.save() + GaGlobalCourseCreatorRole().add_users(global_staff) diff --git a/cms/djangoapps/contentstore/views/access.py b/cms/djangoapps/contentstore/views/access.py index a5348c702a5d..5d25a4879ec3 100644 --- a/cms/djangoapps/contentstore/views/access.py +++ b/cms/djangoapps/contentstore/views/access.py @@ -1,6 +1,6 @@ """ Helper methods for determining user access permissions in Studio """ -from student.roles import CourseInstructorRole +from student.roles import CourseInstructorRole, GaGlobalCourseCreatorRole from student import auth @@ -16,7 +16,8 @@ def get_user_role(user, course_id): :param course_id: the course_id of the course we're interested in """ # afaik, this is only used in lti - if auth.user_has_role(user, CourseInstructorRole(course_id)): + # Note: GaGlobalCourseCreator has more authority than a instructor in studio (#2150) + if auth.user_has_role(user, CourseInstructorRole(course_id)) or auth.user_has_role(user, GaGlobalCourseCreatorRole()): return 'instructor' else: return 'staff' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index c4f7177db1d9..e9db5fb9939a 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -65,7 +65,11 @@ from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.djangoapps.ga_optional.api import is_available -from openedx.core.djangoapps.ga_optional.models import CUSTOM_LOGO_OPTION_KEY, LIBRARY_OPTION_KEY +from openedx.core.djangoapps.ga_optional.models import ( + CUSTOM_LOGO_OPTION_KEY, + LIBRARY_OPTION_KEY, + PROGRESS_RESTRICTION_OPTION_KEY +) from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.utils import get_programs @@ -77,7 +81,7 @@ from student import auth from student.auth import has_course_author_access, has_studio_write_access, has_studio_read_access from student.roles import ( - CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff, UserBasedRole + CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GaGlobalCourseCreatorRole, GlobalStaff, UserBasedRole, ) from util.date_utils import get_default_time_display from util.json_request import JsonResponse, JsonResponseBadRequest, expect_json @@ -293,8 +297,9 @@ def course_rerun_handler(request, course_key_string): GET html: return html page with form to rerun a course for the given course id """ - # Only global staff (PMs) are able to rerun courses during the soft launch - if not GlobalStaff().has_user(request.user): + # Only global staff (PMs) and GaGlobalCourseCreator are able to rerun courses during the soft launch + # Note: GaGlobalCourseCreator has access to rerun (#2150) + if not GlobalStaff().has_user(request.user) and not GaGlobalCourseCreatorRole().has_user(request.user): raise PermissionDenied() course_key = CourseKey.from_string(course_key_string) with modulestore().bulk_operations(course_key): @@ -319,8 +324,9 @@ def course_search_index_handler(request, course_key_string): html: return status of indexing task json: return status of indexing task """ - # Only global staff (PMs) are able to index courses - if not GlobalStaff().has_user(request.user): + # Only global staff (PMs) and GaGlobalCourseCreator are able to index courses + # Note: GaGlobalCourseCreator has access to course search index (#2150) + if not GlobalStaff().has_user(request.user) and not GaGlobalCourseCreatorRole().has_user(request.user): raise PermissionDenied() course_key = CourseKey.from_string(course_key_string) content_type = request.META.get('CONTENT_TYPE', None) @@ -489,11 +495,14 @@ def format_library_for_view(library): 'user': request.user, 'request_course_creator_url': reverse('contentstore.views.request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), - 'rerun_creator_status': GlobalStaff().has_user(request.user), + # Note: GaGlobalCourseCreator can see the rerun status (#2150) + 'rerun_creator_status': GlobalStaff().has_user(request.user) or GaGlobalCourseCreatorRole().has_user(request.user), 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False), 'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True), 'maintenance_message': maintenance_message, - 'is_programs_enabled': programs_config.is_studio_tab_enabled and request.user.is_staff, + # Note: GaGlobalCourseCreator has same authority a global staff in studio (#2150) + 'is_programs_enabled': programs_config.is_studio_tab_enabled and ( + request.user.is_staff or GaGlobalCourseCreatorRole().has_user(request.user)), 'programs': programs, 'program_authoring_url': reverse('programs'), }) @@ -527,9 +536,11 @@ def format_library_for_view(library, course): instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role() global_staff = GlobalStaff().has_user(request.user) + # Note: GaGlobalCourseCreator has access to library listing (#2150) + ga_global_course_creator = GaGlobalCourseCreatorRole().has_user(request.user) if not course_module or not is_available(LIBRARY_OPTION_KEY, course_key): raise Http404 - if not instructor_courses and not global_staff: + if not instructor_courses and not global_staff and not ga_global_course_creator: raise Http404 target_libraries = course_module.target_library @@ -643,7 +654,8 @@ def course_index(request, course_key): 'action_state_id': current_action.id, }, ) if current_action else None, - 'library_option': is_available(LIBRARY_OPTION_KEY, course_key) + 'library_option': is_available(LIBRARY_OPTION_KEY, course_key), + 'is_restricted_in_progress': is_available(PROGRESS_RESTRICTION_OPTION_KEY, course_key), }) @@ -652,7 +664,8 @@ def get_courses_accessible_to_user(request): Try to get all courses by first reversing django groups and fallback to old method if it fails Note: overhead of pymongo reads will increase if getting courses from django groups fails """ - if GlobalStaff().has_user(request.user): + # Note: GaGlobalCourseCreator has access to courses (#2150) + if GlobalStaff().has_user(request.user) or GaGlobalCourseCreatorRole().has_user(request.user): # user has global access so no need to get courses from django groups courses, in_process_course_actions = _accessible_courses_list(request) else: @@ -742,7 +755,9 @@ def _create_or_rerun_course(request): Returns the destination course_key and overriding fields for the new course. Raises DuplicateCourseError and InvalidKeyError """ - if not auth.user_has_role(request.user, CourseCreatorRole()): + # Note: GaGlobalCourseCreator has access to create or rerun course (#2150) + if not auth.user_has_role(request.user, CourseCreatorRole()) \ + and not auth.user_has_role(request.user, GaGlobalCourseCreatorRole()): raise PermissionDenied() try: @@ -1022,7 +1037,9 @@ def settings_handler(request, course_key_string): ) about_page_editable = not marketing_site_enabled - enrollment_end_editable = GlobalStaff().has_user(request.user) or not marketing_site_enabled + # Note: GaGlobalCourseCreator can edit enrollment end date the course (#2150) + enrollment_end_editable = GlobalStaff().has_user(request.user) or not marketing_site_enabled or \ + GaGlobalCourseCreatorRole().has_user(request.user) short_description_editable = settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) self_paced_enabled = SelfPacedConfiguration.current().enabled @@ -1668,7 +1685,8 @@ def _get_course_creator_status(user): If the user passed in has not previously visited the index page, it will be added with status 'unrequested' if the course creator group is in use. """ - if user.is_staff: + # Note: GaGlobalCourseCreator can create a course (#2150) + if user.is_staff or GaGlobalCourseCreatorRole().has_user(user): course_creator_status = 'granted' elif settings.FEATURES.get('DISABLE_COURSE_CREATION', False): course_creator_status = 'disallowed_for_this_site' diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index d88245bdf72f..6bd63cd0faf1 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -964,6 +964,10 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F else: xblock_info["staff_only_message"] = False + if xblock.category == "vertical": + xblock_info["progress_restriction"] = \ + xblock.fields['progress_restriction'].to_json(xblock.progress_restriction) + return xblock_info diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index cb07fc118dfd..7c60955cf7f2 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -33,7 +33,8 @@ from student.auth import ( STUDIO_VIEW_USERS, STUDIO_EDIT_ROLES, get_user_permissions, has_studio_read_access, has_studio_write_access ) -from student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole, GlobalStaff, UserBasedRole +from student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole, GlobalStaff, UserBasedRole, \ + GaGlobalCourseCreatorRole from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest __all__ = ['library_handler', 'course_library_handler', 'manage_library_users'] @@ -92,10 +93,12 @@ def course_library_handler(request, course_key_string=None, library_key_string=N instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role() global_staff = GlobalStaff().has_user(request.user) + # Note: GaGlobalCourseCreator has access to course libraries (#2150) + ga_global_course_creator = GaGlobalCourseCreatorRole().has_user(request.user) course_key = CourseKey.from_string(course_key_string) if not is_available(LIBRARY_OPTION_KEY, course_key): raise Http404 - if not instructor_courses and not global_staff: + if not instructor_courses and not global_staff and not ga_global_course_creator: raise Http404 if request.method == 'POST': diff --git a/cms/djangoapps/contentstore/views/tests/test_access.py b/cms/djangoapps/contentstore/views/tests/test_access.py index c38a292ba7a6..d9a2ae0c1f47 100644 --- a/cms/djangoapps/contentstore/views/tests/test_access.py +++ b/cms/djangoapps/contentstore/views/tests/test_access.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.contrib.auth.models import User -from student.roles import CourseInstructorRole, CourseStaffRole +from student.roles import CourseInstructorRole, CourseStaffRole, GaGlobalCourseCreatorRole from student.tests.factories import AdminFactory from student.auth import add_users from contentstore.views.access import get_user_role @@ -22,6 +22,7 @@ def setUp(self): self.global_admin = AdminFactory() self.instructor = User.objects.create_user('testinstructor', 'testinstructor+courses@edx.org', 'foo') self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo') + self.ga_global_course_creator = User.objects.create_user('gaglobalcoursecreator', 'gaglobalcoursecreator+courses@edx.org', 'foo') self.course_key = SlashSeparatedCourseKey('mitX', '101', 'test') def test_get_user_role_instructor(self): @@ -38,6 +39,11 @@ def test_get_user_role_instructor(self): 'instructor', get_user_role(self.instructor, self.course_key) ) + add_users(self.global_admin, GaGlobalCourseCreatorRole(), self.ga_global_course_creator) + self.assertEqual( + 'instructor', + get_user_role(self.ga_global_course_creator, self.course_key) + ) def test_get_user_role_staff(self): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py index 9eba1e333311..3a14c72e95cf 100644 --- a/cms/djangoapps/contentstore/views/tests/test_assets.py +++ b/cms/djangoapps/contentstore/views/tests/test_assets.py @@ -9,7 +9,7 @@ from mock import patch from django.conf import settings -from contentstore.tests.utils import CourseTestCase +from contentstore.tests.utils import CourseTestCase, switch_ga_global_course_creator from contentstore.views import assets from contentstore.utils import reverse_course_url from xmodule.assetstore import AssetMetadata @@ -72,6 +72,15 @@ def get_sample_asset(self, name, asset_type='text'): return sample_asset +class AssetsTestCaseWithGaGlobalCourseCreator(AssetsTestCase): + """ + Parent class for all asset tests. + """ + def setUp(self): + super(AssetsTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class BasicAssetsTestCase(AssetsTestCase): """ Test getting assets via html w/o additional args @@ -163,6 +172,15 @@ def test_library_option(self): self.assertIn('class="nav-item nav-manage-library"', resp.content) +class BasicAssetsTestCaseWithGaGlobalCourseCreator(BasicAssetsTestCase): + """ + Test getting assets via html w/o additional args + """ + def setUp(self): + super(BasicAssetsTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class PaginationTestCase(AssetsTestCase): """ Tests the pagination of assets returned from the REST API. @@ -282,6 +300,15 @@ def assert_correct_filter_response(self, url, filter_type, filter_value): self.assertIn(content_type, requested_file_types) +class PaginationTestCaseWithGaGlobalCourseCreator(PaginationTestCase): + """ + Tests the pagination of assets returned from the REST API. + """ + def setUp(self): + super(PaginationTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + @ddt class UploadTestCase(AssetsTestCase): """ @@ -322,6 +349,15 @@ def test_file_size(self, case, get_file_size): self.assertEquals(resp.status_code, status_code) +class UploadTestCaseWithGaGlobalCourseCreator(UploadTestCase): + """ + Unit tests for uploading a file + """ + def setUp(self): + super(UploadTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class DownloadTestCase(AssetsTestCase): """ Unit tests for downloading a file. @@ -355,6 +391,15 @@ def test_pickling_calls(self, patched_find_asset_metadata): self.assertFalse(patched_find_asset_metadata.called) +class DownloadTestCaseWithGaGlobalCourseCreator(DownloadTestCase): + """ + Unit tests for downloading a file. + """ + def setUp(self): + super(DownloadTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class AssetToJsonTestCase(AssetsTestCase): """ Unit test for transforming asset information into something @@ -441,6 +486,15 @@ def post_asset_update(lock, course): verify_asset_locked_state(False) +class LockAssetTestCaseWithGaGlobalCourseCreator(LockAssetTestCase): + """ + Unit test for locking and unlocking an asset. + """ + def setUp(self): + super(LockAssetTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class DeleteAssetTestCase(AssetsTestCase): """ Unit test for removing an asset. @@ -511,3 +565,12 @@ def test_delete_asset_with_invalid_thumbnail(self): contentstore().save(self.content) resp = self.client.delete(test_url, HTTP_ACCEPT="application/json") self.assertEquals(resp.status_code, 204) + + +class DeleteAssetTestCaseWithGaGlobalCourseCreator(DeleteAssetTestCase): + """ + Unit test for removing an asset. + """ + def setUp(self): + super(DeleteAssetTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index b22e06327fe6..09f834b15228 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -11,6 +11,7 @@ from django.utils import http import contentstore.views.component as views +from contentstore.tests.utils import switch_ga_global_course_creator from contentstore.views.tests.utils import StudioPageTestCase from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import ItemFactory @@ -185,3 +186,13 @@ def test_container_page_with_valid_and_invalid_usage_key_string(self): usage_key_string=unicode(self.vertical.location) ) self.assertEqual(response.status_code, 200) + + +class ContainerPageTestCaseWithGaGlobalCourseCreator(ContainerPageTestCase): + """ + Unit tests for the container page. + """ + + def setUp(self): + super(ContainerPageTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/views/tests/test_course.py b/cms/djangoapps/contentstore/views/tests/test_course.py index 06be3ba49d39..4e2e4064259d 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course.py +++ b/cms/djangoapps/contentstore/views/tests/test_course.py @@ -3,11 +3,14 @@ """ import json +from django.core.urlresolvers import reverse from django.test.utils import override_settings -from contentstore.tests.utils import CourseTestCase +from contentstore.tests.utils import CourseTestCase, switch_ga_global_course_creator from contentstore.utils import reverse_course_url from openedx.core.djangoapps.ga_optional.models import CourseOptionalConfiguration +from student.roles import CourseInstructorRole, CourseStaffRole +from student.tests.factories import UserFactory from xmodule.modulestore.tests.factories import CourseFactory @@ -83,6 +86,26 @@ def test_app_json_for_get(self): self.assertIn('custom_logo_name', whole_model) self.assertEqual(whole_model['custom_logo_name'], '') + def test_permission(self): + # Global staff + response = self.client.get_html(self.course_details_url) + self.assertEquals(response.status_code, 200) + self.assertIn("Course Category", response.content) + + # GaGlobalCourseCreator + switch_ga_global_course_creator(self.user) + response = self.client.get_html(self.course_details_url) + self.assertEquals(response.status_code, 200) + self.assertIn("Course Category", response.content) + + # Course staff + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + self.client.login(username=user.username, password='test') + response = self.client.get_html(self.course_details_url) + self.assertEquals(response.status_code, 200) + self.assertNotIn("Course Category", response.content) + class TestLibraryOption(CourseTestCase): @@ -134,3 +157,102 @@ def test_library_listing_has_error(self): self.client.login(username=ns_user, password=password) response = self.client.get(library_listing_url) self.assertEqual(response.status_code, 403) + + def test_permission(self): + self.url = reverse_course_url('library_listing', self.course.id) + + # Global staff + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + + # GaGlobalCourseCreator + switch_ga_global_course_creator(self.user) + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + + # Course staff + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + self.client.login(username=user.username, password='test') + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 404) + + +class TestLibraryOptionWithGaGlobalCourseCreator(TestLibraryOption): + def setUp(self): + super(TestLibraryOptionWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + +class TestCourseRerunHandler(CourseTestCase): + def setUp(self): + super(TestCourseRerunHandler, self).setUp() + self.url = reverse_course_url('course_rerun_handler', self.course.id) + + def test_permission(self): + # Global staff + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + + # GaGlobalCourseCreator + switch_ga_global_course_creator(self.user) + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + + # Course staff + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + self.client.login(username=user.username, password='test') + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 403) + + +class TestCourseSearchIndexHandler(CourseTestCase): + def setUp(self): + super(TestCourseSearchIndexHandler, self).setUp() + self.url = reverse_course_url('course_search_index_handler', self.course.id) + + def test_permission(self): + # Global staff + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + + # GaGlobalCourseCreator + switch_ga_global_course_creator(self.user) + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + + # Course staff + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + self.client.login(username=user.username, password='test') + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 403) + + +class TestCourseListing(CourseTestCase): + def setUp(self): + super(TestCourseListing, self).setUp() + self.url = reverse('home') + + def test_has_rerun(self): + rerun_hanler_url = reverse_course_url('course_rerun_handler', self.course.id) + + # Global staff + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + self.assertIn(rerun_hanler_url, response.content) + + # GaGlobalCourseCreator + switch_ga_global_course_creator(self.user) + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + self.assertIn(rerun_hanler_url, response.content) + + # Course staff + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + self.client.login(username=user.username, password='test') + response = self.client.get_html(self.url) + self.assertEquals(response.status_code, 200) + self.assertNotIn(rerun_hanler_url, response.content) diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index 02fd40898b8f..a394e859cdf1 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -14,7 +14,7 @@ from django.utils.translation import ugettext as _ from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError -from contentstore.tests.utils import CourseTestCase +from contentstore.tests.utils import CourseTestCase, switch_ga_global_course_creator from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor, reverse_usage_url from contentstore.views.course import ( course_outline_initial_state, reindex_course_and_check_access, _deprecated_blocks_info @@ -282,6 +282,18 @@ def test_course_index_invalid_url(self): self.assertEqual(response.status_code, 404) +class TestCourseIndexWithGaGlobalCourseCreator(TestCourseIndex): + """ + Unit tests for getting the list of courses and the course outline. + """ + def setUp(self): + """ + Add a course with odd characters in the fields + """ + super(TestCourseIndexWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + @ddt.ddt class TestCourseOutline(CourseTestCase): """ @@ -542,6 +554,18 @@ def test_deprecated_blocks_list_updated_correctly(self, delete_vertical): ) +class TestCourseOutlineWithGaGlobalCourseCreator(TestCourseOutline): + """ + Unit tests for the course outline. + """ + def setUp(self): + """ + Set up the for the course outline tests. + """ + super(TestCourseOutlineWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class TestCourseReIndex(CourseTestCase): """ Unit tests for the course outline. diff --git a/cms/djangoapps/contentstore/views/tests/test_course_updates.py b/cms/djangoapps/contentstore/views/tests/test_course_updates.py index 94f92fe6377c..db2daa08b763 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_updates.py @@ -7,6 +7,7 @@ from contentstore.models import PushNotificationConfig from contentstore.tests.test_course_settings import CourseTestCase +from contentstore.tests.utils import switch_ga_global_course_creator from contentstore.utils import reverse_course_url, reverse_usage_url from opaque_keys.edx.keys import UsageKey from xmodule.modulestore.django import modulestore @@ -314,3 +315,10 @@ def test_notifications_error_from_parse(self, mock_parse_push, mock_log_exceptio mock_parse_push.alert.side_effect = ParseError self.post_course_update(send_push_notification=True) self.assertTrue(mock_log_exception.called) + + +class CourseUpdateTestWithGaGlobalCourseCreator(CourseUpdateTest): + + def setUp(self): + super(CourseUpdateTestWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py index a773f5e0e2d1..14aa7a7b9cc6 100644 --- a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py +++ b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py @@ -8,7 +8,7 @@ from django.contrib.auth.models import User from django.test.client import RequestFactory -from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase +from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, switch_ga_global_course_creator from contentstore.utils import reverse_url from contentstore.views.entrance_exam import create_entrance_exam, update_entrance_exam, delete_entrance_exam,\ add_entrance_exam_milestone, remove_entrance_exam_milestone_reference @@ -327,3 +327,9 @@ def test_entrance_exam_feature_flag_gating(self): # No return, so we'll just ensure no exception is thrown update_entrance_exam(request, self.course.id, {}) + + +class EntranceExamHandlerTestsWithGaGlobalCourseCreator(EntranceExamHandlerTests): + def setUp(self): + super(EntranceExamHandlerTestsWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py index a498ac86f8c8..062727961964 100644 --- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py +++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py @@ -8,7 +8,7 @@ from contentstore.utils import reverse_course_url, reverse_usage_url from contentstore.views.component import SPLIT_TEST_COMPONENT_TYPE from contentstore.course_group_config import GroupConfiguration -from contentstore.tests.utils import CourseTestCase +from contentstore.tests.utils import CourseTestCase, switch_ga_global_course_creator from xmodule.partitions.partitions import Group, UserPartition from xmodule.modulestore.tests.factories import ItemFactory from xmodule.validation import StudioValidation, StudioValidationMessage @@ -298,6 +298,15 @@ def test_lazily_creates_cohort_configuration(self): self.assertEqual(len(self.course.user_partitions), 0) +class GroupConfigurationsListHandlerTestCaseWithGaGlobalCourseCreator(GroupConfigurationsListHandlerTestCase): + """ + Test cases for group_configurations_list_handler. + """ + def setUp(self): + super(GroupConfigurationsListHandlerTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods): """ Test cases for group_configurations_detail_handler. @@ -601,6 +610,15 @@ def test_cannot_delete_non_existent_group_configuration(self): self.assertEqual(user_partititons[0].name, 'Name 0') +class GroupConfigurationsDetailHandlerTestCaseWithGaGlobalCourseCreator(GroupConfigurationsDetailHandlerTestCase): + """ + Test cases for group_configurations_detail_handler. + """ + def setUp(self): + super(GroupConfigurationsDetailHandlerTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): """ Tests for usage information of configurations and content groups. @@ -899,6 +917,15 @@ def test_can_handle_multiple_partitions(self): self.assertEqual(actual.keys(), [0]) +class GroupConfigurationsUsageInfoTestCaseWithGaGlobalCourseCreator(GroupConfigurationsUsageInfoTestCase): + """ + Tests for usage information of configurations and content groups. + """ + def setUp(self): + super(GroupConfigurationsUsageInfoTestCaseWithGaGlobalCourseCreator, self).setUp() + switch_ga_global_course_creator(self.user) + + class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods): """ Tests for validation in Group Configurations. diff --git a/cms/djangoapps/contentstore/views/tests/test_header_menu.py b/cms/djangoapps/contentstore/views/tests/test_header_menu.py index c27048452a9c..da410ab4f09e 100644 --- a/cms/djangoapps/contentstore/views/tests/test_header_menu.py +++ b/cms/djangoapps/contentstore/views/tests/test_header_menu.py @@ -6,7 +6,7 @@ from django.conf import settings from django.test.utils import override_settings -from contentstore.tests.utils import CourseTestCase +from contentstore.tests.utils import CourseTestCase, switch_ga_global_course_creator from contentstore.utils import reverse_course_url FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() @@ -45,3 +45,12 @@ def test_header_menu_with_web_certs_enabled(self): resp = self.client.get(outline_url, HTTP_ACCEPT='text/html') self.assertEqual(resp.status_code, 200) self.assertContains(resp, '
  • ').text(DateUtils.getJstDate(utcDate)) + ); } }, @@ -235,20 +249,24 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } if ($.isEmptyObject(this.model.validationErrors)) { this.model.trigger('valid'); + this.setJstDate(this.getValue()); } else { this.model.trigger('invalid'); + this.setJstDate(''); } } }); DueDateEditor = BaseDateEditor.extend({ fieldName: 'due', + jstDateFieldName: '#due_jst_date', templateName: 'due-date-editor', className: 'modal-section-content has-actions due-date-input grading-due-date', errorSelector: '#due-date-error-message', events: { - 'change #due_date': 'validate' + 'change #due_date': 'validate', + 'change #due_time': 'validate' }, initialize: function() { @@ -276,13 +294,15 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ReleaseDateEditor = BaseDateEditor.extend({ fieldName: 'start', + jstDateFieldName: '#start_jst_date', templateName: 'release-date-editor', className: 'edit-settings-release scheduled-date-input', startingReleaseDate: null, errorSelector: '#release-date-error-message', events: { - 'change #start_date': 'validate' + 'change #start_date': 'validate', + 'change #start_time': 'validate' }, initialize: function() { @@ -709,6 +729,85 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } }); + ProgressRestrictionEditor = AbstractEditor.extend({ + templateName: 'progress-restriction-editor', + className: 'edit-progress-restriction', + PROGRESS_RESTRICTION_TYPE: { + 'no-restriction': 'No Restriction', + 'correct-answer-rate': 'Correct Answer Rate' + }, + + events: { + 'change #progress-restriction-passing-mark': 'validate' + }, + errorSelector: '#progress-restriction-passing-mark-error-message', + + getPassingMark: function() { + var passingMarkInfo = this.model.get('progress_restriction'); + + if (typeof passingMarkInfo === 'undefined') { + return ''; + } else if (passingMarkInfo['type'] === this.PROGRESS_RESTRICTION_TYPE['correct-answer-rate']) { + if (typeof passingMarkInfo['passing_mark'] !== 'undefined') { + return passingMarkInfo['passing_mark']; + } else { + return 0; + } + } else { + return ''; + } + }, + getValue: function() { + return this.$('#progress-restriction-passing-mark').val(); + }, + setValue: function(passingMark) { + this.$('#progress-restriction-passing-mark').val(passingMark); + }, + validate: function() { + var errorMessages = this.$(this.errorSelector), + isValid = true, + value = this.getValue(); + errorMessages.empty(); + if (value && (value.match(/[^0-9]/) || parseInt(value, 10) < 0 || 100 < parseInt(value, 10))) { + isValid = false; + errorMessages.append( + $('
  • ').text(gettext('Please enter an integer between 0 and 100.')) + ); + } + if (isValid) { + this.model.trigger('valid'); + } else { + this.model.trigger('invalid'); + } + }, + getPassingMarkInfo: function() { + var passingMark = parseInt(this.getValue(), 10); + + if (0 < passingMark && passingMark <= 100) { + return { + 'type': this.PROGRESS_RESTRICTION_TYPE['correct-answer-rate'], + 'passing_mark': passingMark + }; + } else { + return { + 'type': this.PROGRESS_RESTRICTION_TYPE['no-restriction'] + }; + } + }, + afterRender: function() { + AbstractEditor.prototype.afterRender.call(this); + this.setValue(this.getPassingMark().toString()); + }, + getRequestData: function() { + return { + publish: 'republish', + metadata: { + progress_restriction: this.getPassingMarkInfo() + } + }; + } + }); + return { getModal: function (type, xblockInfo, options) { if (type === 'edit') { @@ -739,6 +838,12 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', if (xblockInfo.hasVerifiedCheckpoints()) { editors.push(VerificationAccessEditor); } + + /* globals is_restricted_in_progress */ + if (is_restricted_in_progress) { + // add form when set is_restricted_in_progress to true with django admin + editors.push(ProgressRestrictionEditor); + } } /* globals course */ if (course.get('self_paced')) { diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index 1be17e34378f..e1ba8c93a043 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -212,6 +212,7 @@ var DetailsView = ValidatingView.extend({ var div = this.$el.find('#' + this.fieldToSelectorMap[fieldName]); var datefield = $(div).find("input.date"); var timefield = $(div).find("input.time"); + var jstfield = $(div).find("input.jst"); var cachethis = this; var setfield = function () { var newVal = DateUtils.getDate(datefield, timefield), @@ -220,6 +221,7 @@ var DetailsView = ValidatingView.extend({ if (!cacheModel.has(fieldName) || oldTime !== newVal.getTime()) { cachethis.clearValidationErrors(); cachethis.setAndValidate(fieldName, newVal); + jstfield.val(DateUtils.getJstDate(newVal)); } } else { @@ -228,6 +230,7 @@ var DetailsView = ValidatingView.extend({ // (start date is required by the back end). cachethis.clearValidationErrors(); cachethis.setAndValidate(fieldName, null); + jstfield.val(''); } }; @@ -245,10 +248,13 @@ var DetailsView = ValidatingView.extend({ // timepicker doesn't let us set null, so check that we have a time if (date) { DateUtils.setDate(datefield, timefield, date); + var newVal = DateUtils.getDate(datefield, timefield); + jstfield.val(DateUtils.getJstDate(newVal)); } // but reset fields either way else { timefield.val(''); datefield.val(''); + jstfield.val(''); } }, diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml index 37e229f4eba5..791bad739a97 100644 --- a/cms/static/js_test.yml +++ b/cms/static/js_test.yml @@ -70,6 +70,7 @@ lib_paths: - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate.js - xmodule_js/common_static/js/vendor/mock-ajax.js - xmodule_js/common_static/js/vendor/requirejs/text.js + - xmodule_js/common_static/js/vendor/moment.min.js # Paths to source JavaScript files src_paths: diff --git a/cms/static/js_test_squire.yml b/cms/static/js_test_squire.yml index b3d6e1344404..b7098cdfa0f9 100644 --- a/cms/static/js_test_squire.yml +++ b/cms/static/js_test_squire.yml @@ -63,6 +63,7 @@ lib_paths: - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate.js - xmodule_js/common_static/js/vendor/requirejs/text.js + - xmodule_js/common_static/js/vendor/moment.min.js # Paths to source JavaScript files src_paths: diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index f57b2798c507..12e7c6efd3f5 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -710,5 +710,13 @@ margin-bottom: 0; } } + + // UI: passing mark for progress restriction section + .edit-progress-restriction { + .progress-restriction-percentage { + display: inline-block; + width: 30%; + } + } } } diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index 38dd9fd93767..607469592a1d 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -696,6 +696,12 @@ } } + .jst-date { + @extend %t-strong; + @include text-align(right); + width: ($baseline*15); + } + .individual-error, .error-message { margin-bottom: $baseline/2; li { diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 0f9e425b78a6..437cfee46aa8 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -1,6 +1,7 @@ <%inherit file="base.html" /> <%def name="online_help_token()"><% return "outline" %> <%! +import json import logging from util.date_utils import get_default_time_display from django.utils.translation import ugettext as _ @@ -22,11 +23,14 @@ <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'individual-due-days-editor', 'release-date-editor', 'individual-release-days-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor']: +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'individual-due-days-editor', 'release-date-editor', 'individual-release-days-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'progress-restriction-editor']: % endfor + <%block name="page_alert"> diff --git a/cms/templates/js/due-date-editor.underscore b/cms/templates/js/due-date-editor.underscore index 450bba86f7d9..40c5aba676f7 100644 --- a/cms/templates/js/due-date-editor.underscore +++ b/cms/templates/js/due-date-editor.underscore @@ -20,4 +20,5 @@
  • + diff --git a/cms/templates/js/mock/mock-settings-page.underscore b/cms/templates/js/mock/mock-settings-page.underscore index c3411b507c29..d44b512c5b27 100644 --- a/cms/templates/js/mock/mock-settings-page.underscore +++ b/cms/templates/js/mock/mock-settings-page.underscore @@ -17,6 +17,10 @@ (UTC)
    + +
    + +
  • @@ -31,6 +35,10 @@ (UTC)
  • + +
    + +
  • @@ -67,6 +75,10 @@ (UTC) + +
    + +
  • @@ -81,6 +93,10 @@ (UTC) + +
    + +
  • @@ -113,7 +129,7 @@
  • - + First day problems can't submit, this setting is only affect to course list
    @@ -122,12 +138,16 @@ (UTC) + +
    + +
  • - + First day the course is hidden
    @@ -136,6 +156,10 @@ (UTC) + +
    + +
  • diff --git a/cms/templates/js/progress-restriction-editor.underscore b/cms/templates/js/progress-restriction-editor.underscore new file mode 100644 index 000000000000..8c51be509374 --- /dev/null +++ b/cms/templates/js/progress-restriction-editor.underscore @@ -0,0 +1,19 @@ +
    + + +
    diff --git a/cms/templates/js/release-date-editor.underscore b/cms/templates/js/release-date-editor.underscore index 41bf2893ebb8..7e4ceb58b002 100644 --- a/cms/templates/js/release-date-editor.underscore +++ b/cms/templates/js/release-date-editor.underscore @@ -25,5 +25,6 @@ <% } %> + diff --git a/cms/templates/settings.html b/cms/templates/settings.html index fdda8ce299a7..fb493d773d43 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _ from contentstore import utils from openedx.core.lib.js_utils import escape_json_dumps + from student.roles import GaGlobalCourseCreatorRole %> <%block name="header_extras"> @@ -189,6 +190,10 @@

    ${_('Course Schedule')}

    ${_("(UTC)")} + +
    + +
    @@ -239,6 +248,10 @@

    ${_('Course Schedule')}

    ${_("(UTC)")} + +
    + +
    <% enrollment_end_readonly = "readonly aria-readonly=\"true\"" if not enrollment_end_editable else "" @@ -261,6 +274,10 @@

    ${_('Course Schedule')}

    ${_("(UTC)")} + +
    + +
    @@ -277,6 +294,10 @@

    ${_('Course Schedule')}

    ${_("(UTC)")} + +
    + +
  • @@ -291,6 +312,10 @@

    ${_('Course Schedule')}

    ${_("(UTC)")} + +
    + +
  • @@ -337,7 +362,8 @@

    ${_("Introducing Your Course")}

    ${_("Information for prospective students")}
      - % if user.is_staff: + ## Note: GaGlobalCourseCreator can edit course category (#2150) + % if user.is_staff or GaGlobalCourseCreatorRole().has_user(user):
    1. diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index f2e7b7f914ab..5484ca6c5a91 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -9,7 +9,7 @@ from opaque_keys.edx.locator import LibraryLocator from student.roles import GlobalStaff, CourseCreatorRole, CourseStaffRole, CourseInstructorRole, CourseRole, \ - CourseBetaTesterRole, OrgInstructorRole, OrgStaffRole, LibraryUserRole, OrgLibraryUserRole + CourseBetaTesterRole, OrgInstructorRole, OrgStaffRole, LibraryUserRole, OrgLibraryUserRole, GaGlobalCourseCreatorRole # Studio permissions: @@ -64,6 +64,9 @@ def get_user_permissions(user, course_key, org=None): # global staff, org instructors, and course instructors have all permissions: if GlobalStaff().has_user(user) or OrgInstructorRole(org=org).has_user(user): return all_perms + if GaGlobalCourseCreatorRole().has_user(user): + # Note: GaGlobalCourseCreator has same authority a global staff in studio (#2150) + return all_perms if course_key and user_has_role(user, CourseInstructorRole(course_key)): return all_perms # Staff have all permissions except EDIT_ROLES: @@ -147,8 +150,11 @@ def _check_caller_authority(caller, role): # superuser if GlobalStaff().has_user(caller): return + if GaGlobalCourseCreatorRole().has_user(caller): + # Note: GaGlobalCourseCreator has same authority a global staff in studio (#2150) + return - if isinstance(role, (GlobalStaff, CourseCreatorRole)): + if isinstance(role, (GlobalStaff, CourseCreatorRole, GaGlobalCourseCreatorRole)): raise PermissionDenied elif isinstance(role, CourseRole): # instructors can change the roles w/in their course if not user_has_role(caller, CourseInstructorRole(role.course_key)): diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 9585d45b3f0e..6d8c4f2949d5 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -23,7 +23,8 @@ from django.utils.translation import ugettext_lazy as _ -from student.models import UserStanding +from biz.djangoapps.util import mask_utils +from student.models import Registration, UserStanding from openedx.core.djangoapps.user_api.accounts import EMPLOYEE_NUMBER_LENGTH, EMPLOYEE_NUMBER_REGEX @@ -165,6 +166,11 @@ def save(self): # NOTE(yokose): store resign_reason into db user_account.resign_reason = self.cleaned_data["resign_reason"] user_account.save() + if '@' in self.user.email: + mask_utils.disable_all_additional_info(self.user) + mask_utils.disable_user_info(self.user) + Registration.objects.get(user=self.user).update_masked() + class TrueCheckbox(widgets.CheckboxInput): """ diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index b316025d773d..5b1bfb92e987 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -399,3 +399,25 @@ class GaOldCourseViewerStaffRole(RoleBase): def __init__(self, *args, **kwargs): super(GaOldCourseViewerStaffRole, self).__init__(self.ROLE, *args, **kwargs) + + +@register_access_role +class GaGlobalCourseCreatorRole(RoleBase): + """ + Course creator members. (#2150) + """ + ROLE = "ga_global_course_creator" + + def __init__(self, *args, **kwargs): + super(GaGlobalCourseCreatorRole, self).__init__(self.ROLE, *args, **kwargs) + + +@register_access_role +class GaCourseScorerRole(CourseRole): + """ + A scoring staff member of a course (#2150) + """ + ROLE = "ga_course_scorer" + + def __init__(self, *args, **kwargs): + super(GaCourseScorerRole, self).__init__(self.ROLE, *args, **kwargs) diff --git a/common/djangoapps/student/tests/test_ga_authz.py b/common/djangoapps/student/tests/test_ga_authz.py new file mode 100644 index 000000000000..0a2b38dd19d1 --- /dev/null +++ b/common/djangoapps/student/tests/test_ga_authz.py @@ -0,0 +1,68 @@ +""" +Tests authz.py +""" + +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied +from django.test import TestCase + +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from student.auth import user_has_role, add_users, remove_users +from student.roles import CourseInstructorRole, CourseStaffRole, GaCourseScorerRole, GaGlobalCourseCreatorRole +from student.tests.factories import AdminFactory + + +class CourseGroupTest(TestCase): + """ + Tests for instructor and staff groups for a particular course. + """ + + def setUp(self): + """ Test case setup """ + super(CourseGroupTest, self).setUp() + self.course_key = SlashSeparatedCourseKey('mitX', '101', 'test') + self.global_admin = AdminFactory() + self.creator = User.objects.create_user('testcreator', 'testcreator+courses@edx.org', 'foo') + self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo') + self.ga_global_course_creator = User.objects.create_user('testgaglobalcoursecreator', 'testgaglobalcoursecreator+courses@edx.org', 'foo') + add_users(self.global_admin, GaGlobalCourseCreatorRole(), self.ga_global_course_creator) + self.ga_course_scorer = User.objects.create_user('testgacoursescorer', 'testgacoursescorer+courses@edx.org', 'foo') + add_users(self.global_admin, GaCourseScorerRole(self.course_key), self.ga_course_scorer) + + def test_add_user_to_course_group(self): + """ + Tests adding user to course group (happy path). + """ + # Create groups for a new course (and assign instructor role to the creator). + self.assertFalse(user_has_role(self.creator, CourseInstructorRole(self.course_key))) + add_users(self.ga_global_course_creator, CourseInstructorRole(self.course_key), self.creator) + add_users(self.ga_global_course_creator, CourseStaffRole(self.course_key), self.creator) + self.assertTrue(user_has_role(self.creator, CourseInstructorRole(self.course_key))) + + def test_add_user_to_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of add_user_to_course_group is not instructor role. + """ + with self.assertRaises(PermissionDenied): + add_users(self.ga_course_scorer, CourseStaffRole(self.course_key), self.staff) + + def test_remove_user_from_course_group(self): + """ + Tests removing user from course group (happy path). + """ + add_users(self.ga_global_course_creator, CourseInstructorRole(self.course_key), self.creator) + add_users(self.ga_global_course_creator, CourseStaffRole(self.course_key), self.creator) + + remove_users(self.ga_global_course_creator, CourseInstructorRole(self.course_key), self.creator) + self.assertFalse(user_has_role(self.creator, CourseInstructorRole(self.course_key))) + + remove_users(self.ga_global_course_creator, CourseStaffRole(self.course_key), self.creator) + self.assertFalse(user_has_role(self.creator, CourseStaffRole(self.course_key))) + + def test_remove_user_from_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of remove_user_from_course_group is not instructor role. + """ + add_users(self.ga_global_course_creator, CourseStaffRole(self.course_key), self.staff) + with self.assertRaises(PermissionDenied): + remove_users(self.ga_course_scorer, CourseStaffRole(self.course_key), self.staff) diff --git a/common/djangoapps/student/tests/test_resign.py b/common/djangoapps/student/tests/test_resign.py index 7618827cc73c..5dcb79d36dfd 100644 --- a/common/djangoapps/student/tests/test_resign.py +++ b/common/djangoapps/student/tests/test_resign.py @@ -18,7 +18,7 @@ from student.models import UserStanding from student.views import resign, resign_confirm -from student.tests.factories import UserFactory +from student.tests.factories import RegistrationFactory, UserFactory from student.tests.test_email import mock_render_to_string @@ -29,12 +29,13 @@ class ResignTests(TestCase): request_factory = RequestFactory() def setUp(self): - self.user = UserFactory.create() + self.user = UserFactory.create(email='user@example.com') self.user.is_active = False self.user.save() self.token = default_token_generator.make_token(self.user) self.uidb36 = int_to_base36(self.user.id) self.resign_reason = 'a' * 1000 + RegistrationFactory.create(user=self.user, masked=False) def test_resign_404(self): """Ensures that no get request to /resign/ is allowed""" @@ -115,13 +116,17 @@ def test_resign_confirm_with_good_token(self): UserStanding.objects.get, user=self.user) + @patch('biz.djangoapps.util.mask_utils.disable_all_additional_info') + @patch('biz.djangoapps.util.mask_utils.disable_user_info') @patch('student.views.logout_user') - def test_resign_confirm_with_good_reason(self, logout_user): + def test_resign_confirm_with_good_reason(self, disable_all_additional_info, disable_user_info, logout_user): """Ensures that post request with good resign_reason to /resign_confirm/ makes the user logged out and disabled """ good_req = self.request_factory.post('/resign_confirm/{0}-{1}/'.format(self.uidb36, self.token), {'resign_reason': self.resign_reason}) good_resp = resign_confirm(good_req, self.uidb36, self.token) + self.assertTrue(disable_all_additional_info.called) + self.assertTrue(disable_user_info.called) self.assertTrue(logout_user.called) self.assertEquals(good_resp.status_code, 200) diff --git a/common/djangoapps/student/tests/test_roles.py b/common/djangoapps/student/tests/test_roles.py index d27dcc38c42e..6d7e5ebbb3d9 100644 --- a/common/djangoapps/student/tests/test_roles.py +++ b/common/djangoapps/student/tests/test_roles.py @@ -4,12 +4,14 @@ import ddt from django.test import TestCase -from courseware.tests.factories import GaOldCourseViewerStaffFactory, UserFactory, StaffFactory, InstructorFactory +from courseware.tests.factories import GaCourseScorerFactory, GaGlobalCourseCreatorFactory, GaOldCourseViewerStaffFactory, \ + UserFactory, StaffFactory, InstructorFactory from student.tests.factories import AnonymousUserFactory from student.roles import ( GlobalStaff, CourseRole, CourseStaffRole, CourseInstructorRole, - OrgStaffRole, OrgInstructorRole, RoleCache, CourseBetaTesterRole + OrgStaffRole, OrgInstructorRole, RoleCache, CourseBetaTesterRole, + GaCourseScorerRole ) from opaque_keys.edx.locations import SlashSeparatedCourseKey @@ -29,12 +31,16 @@ def setUp(self): self.course_staff = StaffFactory(course_key=self.course_key) self.course_instructor = InstructorFactory(course_key=self.course_key) self.ga_old_course_viewer = GaOldCourseViewerStaffFactory() + self.ga_global_course_creator = GaGlobalCourseCreatorFactory() + self.ga_course_scorer = GaCourseScorerFactory(course_key=self.course_key) def test_global_staff(self): self.assertFalse(GlobalStaff().has_user(self.student)) self.assertFalse(GlobalStaff().has_user(self.course_staff)) self.assertFalse(GlobalStaff().has_user(self.course_instructor)) self.assertFalse(GlobalStaff().has_user(self.ga_old_course_viewer)) + self.assertFalse(GlobalStaff().has_user(self.ga_global_course_creator)) + self.assertFalse(GlobalStaff().has_user(self.ga_course_scorer)) self.assertTrue(GlobalStaff().has_user(self.global_staff)) def test_group_name_case_sensitive(self): @@ -76,6 +82,27 @@ def test_course_role(self): "Student still has access to {}".format(self.course_key) ) + def test_course_role_with_ga_course_scorer(self): + """ + Test that giving a user a course role enables access appropriately + """ + self.assertFalse( + GaCourseScorerRole(self.course_key).has_user(self.student), + "Student has premature access to {}".format(self.course_key) + ) + GaCourseScorerRole(self.course_key).add_users(self.student) + self.assertTrue( + GaCourseScorerRole(self.course_key).has_user(self.student), + "Student doesn't have access to {}".format(unicode(self.course_key)) + ) + + # remove access and confirm + GaCourseScorerRole(self.course_key).remove_users(self.student) + self.assertFalse( + GaCourseScorerRole(self.course_key).has_user(self.student), + "Student still has access to {}".format(self.course_key) + ) + def test_org_role(self): """ Test that giving a user an org role enables access appropriately @@ -171,6 +198,7 @@ class RoleCacheTestCase(TestCase): (OrgStaffRole(IN_KEY.org), ('staff', None, 'edX')), (OrgInstructorRole(IN_KEY.org), ('instructor', None, 'edX')), (CourseBetaTesterRole(IN_KEY), ('beta_testers', IN_KEY, 'edX')), + (GaCourseScorerRole(IN_KEY), ('ga_course_scorer', IN_KEY, 'edX')), ) def setUp(self): diff --git a/common/djangoapps/student/tests/test_userstanding.py b/common/djangoapps/student/tests/test_userstanding.py index 58700aa60f2f..d7e2b1725194 100644 --- a/common/djangoapps/student/tests/test_userstanding.py +++ b/common/djangoapps/student/tests/test_userstanding.py @@ -5,10 +5,12 @@ import ddt import json import unittest +import uuid -from student.tests.factories import CourseEnrollmentFactory, UserFactory, UserStandingFactory +from student.tests.factories import CourseEnrollmentFactory, RegistrationFactory, UserFactory, UserStandingFactory from student.models import CourseEnrollment, UserStanding from django.conf import settings +from django.contrib.auth.models import User from django.test import TestCase, Client from django.core.urlresolvers import reverse from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -58,6 +60,9 @@ def setUp(self): account_status=UserStanding.ACCOUNT_DISABLED, changed_by=self.admin ) + RegistrationFactory.create(user=self.admin, activation_key=uuid.uuid4().hex, masked=False) + RegistrationFactory.create(user=self.bad_user, activation_key=uuid.uuid4().hex, masked=False) + RegistrationFactory.create(user=self.good_user, activation_key=uuid.uuid4().hex, masked=False) # set stock url to test disabled accounts' access to site self.some_url = '/' @@ -78,6 +83,7 @@ def test_disable_account(self): UserStanding.objects.get(user=self.good_user).account_status, UserStanding.ACCOUNT_DISABLED ) + self.assertTrue(User.objects.get(pk=self.good_user.id).registration.masked) content = json.loads(response.content) self.assertEqual(content['user_name'], self.good_user.username) self.assertEqual(content['user_mail'], self.good_user.email) @@ -86,6 +92,26 @@ def test_disable_account(self): UserStanding.ACCOUNT_DISABLED ) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_cant_disable_account_for_myself(self): + response = self.admin_client.post(reverse('disable_account_ajax'), { + 'username_or_email': self.admin.username, + 'user': self.admin, + 'account_action': 'disable', + }) + self.assertFalse(User.objects.get(pk=self.admin.id).registration.masked) + content = json.loads(response.content) + self.assertEqual(content['user_name'], self.admin.username) + self.assertEqual(content['user_mail'], self.admin.email) + self.assertEqual( + content['account_status'], + UserStanding.ACCOUNT_ENABLED + ) + self.assertEqual( + content['message'], + 'You can not change of yourself.' + ) + def test_disabled_account_redirect_to_disabled_account_page(self): response = self.bad_user_client.get(self.some_url) self.assertRedirects(response, 'disabled_account') diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 0521255a02dc..94843d264be3 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -79,7 +79,7 @@ from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error -from courseware.access import has_access, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW +from courseware.access import has_access from django_comment_common.models import Role @@ -134,6 +134,7 @@ from openedx.core.djangoapps.ga_self_paced import api as self_paced_api from biz.djangoapps.ga_contract.models import ContractDetail +from biz.djangoapps.util import mask_utils log = logging.getLogger("edx.student") @@ -594,8 +595,6 @@ def dashboard(request): staff_access = True errored_courses = modulestore().get_errored_courses() - ga_old_course_viewer_access = has_access(user, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW, 'global') - show_courseware_links_for = frozenset( enrollment.course_id for enrollment in course_enrollments if has_access(request.user, 'load', enrollment.course_overview) @@ -1433,20 +1432,31 @@ def disable_account_ajax(request): user_account, _success = UserStanding.objects.get_or_create( user=user, defaults={'changed_by': request.user}, ) + if user.standing and user.standing.account_status == UserStanding.ACCOUNT_DISABLED: + account_status = UserStanding.ACCOUNT_DISABLED + else: + account_status = UserStanding.ACCOUNT_ENABLED + if account_action == 'disable': - user_account.account_status = UserStanding.ACCOUNT_DISABLED - context['message'] = _("Successfully disabled {}'s account").format(username) - log.info(u"%s disabled %s's account", request.user, username) + if user == request.user: + context['message'] = _("You can not change of yourself.") + context['account_status'] = _(account_status) + return JsonResponse(context) + elif '@' in user.email: + mask_utils.disable_all_additional_info(user) + mask_utils.disable_user_info(user) + Registration.objects.get(user=user).update_masked() + user_account.account_status = UserStanding.ACCOUNT_DISABLED + context['message'] = _("Successfully disabled {}'s account").format(username) + log.info(u"%s disabled %s's account", request.user, username) + else: + context['message'] = _("Students whose accounts have been disabled") elif account_action == 'reenable': user_account.account_status = UserStanding.ACCOUNT_ENABLED context['message'] = _("Successfully reenabled {}'s account").format(username) log.info(u"%s reenabled %s's account", request.user, username) elif account_action == 'view_account_status': - account_status = user.standing.account_status if user.standing else None - if account_status == UserStanding.ACCOUNT_DISABLED: - context['account_status'] = _(UserStanding.ACCOUNT_DISABLED) - else: - context['account_status'] = _(UserStanding.ACCOUNT_ENABLED) + context['account_status'] = _(account_status) context['message'] = "" return JsonResponse(context) elif account_action == 'view_course_enrollment': diff --git a/common/djangoapps/util/date_utils.py b/common/djangoapps/util/date_utils.py index d4bfff685d91..5772238289ea 100644 --- a/common/djangoapps/util/date_utils.py +++ b/common/djangoapps/util/date_utils.py @@ -5,8 +5,8 @@ from datetime import datetime, timedelta import re -from pytz import timezone, UTC, UnknownTimeZoneError from django.utils.translation import pgettext, ugettext +from pytz import timezone, utc, UnknownTimeZoneError def get_default_time_display(dtime): @@ -52,7 +52,7 @@ def get_time_display(dtime, format_string=None, coerce_tz=None): try: to_tz = timezone(coerce_tz) except UnknownTimeZoneError: - to_tz = UTC + to_tz = utc dtime = to_tz.normalize(dtime.astimezone(to_tz)) if dtime is None or format_string is None: return get_default_time_display(dtime) @@ -78,7 +78,7 @@ def to_timestamp(datetime_value): Convert a datetime into a timestamp, represented as the number of seconds since January 1, 1970 UTC. """ - return int((datetime_value - datetime(1970, 1, 1, tzinfo=UTC)).total_seconds()) + return int((datetime_value - datetime(1970, 1, 1, tzinfo=utc)).total_seconds()) def from_timestamp(timestamp): @@ -89,7 +89,7 @@ def from_timestamp(timestamp): If the timestamp cannot be converted, returns None instead. """ try: - return datetime.utcfromtimestamp(int(timestamp)).replace(tzinfo=UTC) + return datetime.utcfromtimestamp(int(timestamp)).replace(tzinfo=utc) except (ValueError, TypeError): return None diff --git a/common/djangoapps/util/tests/test_date_utils.py b/common/djangoapps/util/tests/test_date_utils.py index 05b2e56e4f79..a2222813cbfc 100644 --- a/common/djangoapps/util/tests/test_date_utils.py +++ b/common/djangoapps/util/tests/test_date_utils.py @@ -9,8 +9,7 @@ import ddt from mock import patch from nose.tools import assert_equals, assert_false # pylint: disable=no-name-in-module -from pytz import UTC - +from pytz import utc from util.date_utils import ( get_default_time_display, get_time_display, almost_same_datetime, strftime_localized, @@ -19,7 +18,7 @@ def test_get_default_time_display(): assert_equals("", get_default_time_display(None)) - test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) + test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc) assert_equals( "Mar 12, 1992 at 15:03 UTC", get_default_time_display(test_time)) @@ -34,12 +33,12 @@ def test_get_dflt_time_disp_notz(): def test_get_time_disp_ret_empty(): assert_equals("", get_time_display(None)) - test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) + test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc) assert_equals("", get_time_display(test_time, "")) def test_get_time_display(): - test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) + test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc) assert_equals("dummy text", get_time_display(test_time, 'dummy text')) assert_equals("Mar 12 1992", get_time_display(test_time, '%b %d %Y')) assert_equals("Mar 12 1992 UTC", get_time_display(test_time, '%b %d %Y %Z')) @@ -47,15 +46,15 @@ def test_get_time_display(): def test_get_time_pass_through(): - test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) + test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=utc) assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time)) assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, None)) assert_equals("Mar 12, 1992 at 15:03 UTC", get_time_display(test_time, "%")) def test_get_time_display_coerce(): - test_time_standard = datetime(1992, 1, 12, 15, 3, 30, tzinfo=UTC) - test_time_daylight = datetime(1992, 7, 12, 15, 3, 30, tzinfo=UTC) + test_time_standard = datetime(1992, 1, 12, 15, 3, 30, tzinfo=utc) + test_time_daylight = datetime(1992, 7, 12, 15, 3, 30, tzinfo=utc) assert_equals("Jan 12, 1992 at 07:03 PST", get_time_display(test_time_standard, None, coerce_tz="US/Pacific")) assert_equals("Jan 12, 1992 at 15:03 UTC", diff --git a/common/lib/xmodule/xmodule/course_metadata_utils.py b/common/lib/xmodule/xmodule/course_metadata_utils.py index 500ef8485fb2..bff0e75998ef 100644 --- a/common/lib/xmodule/xmodule/course_metadata_utils.py +++ b/common/lib/xmodule/xmodule/course_metadata_utils.py @@ -10,11 +10,12 @@ import dateutil.parser from math import exp -from django.utils.timezone import UTC +from openedx.core.lib.time_zone_utils import get_time_zone_abbr +from pytz import utc from .fields import Date -DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=UTC()) +DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=utc) def clean_course_key(course_key, padding_char): @@ -92,7 +93,7 @@ def has_course_started(start_date): start_date (datetime): The start datetime of the course in question. """ # TODO: This will throw if start_date is None... consider changing this behavior? - return datetime.now(UTC()) > start_date + return datetime.now(utc) > start_date def has_course_ended(end_date): @@ -104,7 +105,7 @@ def has_course_ended(end_date): Arguments: end_date (datetime): The end datetime of the course in question. """ - return datetime.now(UTC()) > end_date if end_date is not None else False + return datetime.now(utc) > end_date if end_date is not None else False def course_starts_within(start_date, look_ahead_days): @@ -116,7 +117,7 @@ def course_starts_within(start_date, look_ahead_days): start_date (datetime): The start datetime of the course in question. look_ahead_days (int): number of days to see in future for course start date. """ - return datetime.now(UTC()) + timedelta(days=look_ahead_days) > start_date + return datetime.now(utc) + timedelta(days=look_ahead_days) > start_date def course_start_date_is_default(start, advertised_start): @@ -131,30 +132,31 @@ def course_start_date_is_default(start, advertised_start): return advertised_start is None and start == DEFAULT_START_DATE -def _datetime_to_string(date_time, format_string, strftime_localized): +def _datetime_to_string(date_time, format_string, time_zone, strftime_localized): """ Formats the given datetime with the given function and format string. - Adds UTC to the resulting string if the format is DATE_TIME or TIME. + Adds time zone abbreviation to the resulting string if the format is DATE_TIME or TIME. Arguments: date_time (datetime): the datetime to be formatted format_string (str): the date format type, as passed to strftime + time_zone (pytz time zone): the time zone to convert to strftime_localized ((datetime, str) -> str): a nm localized string formatting function """ - # TODO: Is manually appending UTC really the right thing to do here? What if date_time isn't UTC? - result = strftime_localized(date_time, format_string) + result = strftime_localized(date_time.astimezone(time_zone), format_string) + abbr = get_time_zone_abbr(time_zone, date_time) return ( - result + u" UTC" if format_string in ['DATE_TIME', 'TIME', 'DAY_AND_TIME'] + result + ' ' + abbr if format_string in ['DATE_TIME', 'TIME', 'DAY_AND_TIME'] else result ) -def course_start_datetime_text(start_date, advertised_start, format_string, ugettext, strftime_localized): +def course_start_datetime_text(start_date, advertised_start, format_string, time_zone, ugettext, strftime_localized): """ Calculates text to be shown to user regarding a course's start - datetime in UTC. + datetime in specified time zone. Prefers .advertised_start, then falls back to .start. @@ -162,6 +164,7 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget start_date (datetime): the course's start datetime advertised_start (str): the course's advertised start date format_string (str): the date format type, as passed to strftime + time_zone (pytz time zone): the time zone to convert to ugettext ((str) -> str): a text localization function strftime_localized ((datetime, str) -> str): a localized string formatting function @@ -174,12 +177,12 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget if parsed_advertised_start is not None: # In the Django implementation of strftime_localized, if # the year is <1900, _datetime_to_string will raise a ValueError. - return _datetime_to_string(parsed_advertised_start, format_string, strftime_localized) + return _datetime_to_string(parsed_advertised_start, format_string, time_zone, strftime_localized) except ValueError: pass return advertised_start.title() elif start_date != DEFAULT_START_DATE: - return _datetime_to_string(start_date, format_string, strftime_localized) + return _datetime_to_string(start_date, format_string, time_zone, strftime_localized) else: _ = ugettext # Translators: TBD stands for 'To Be Determined' and is used when a course @@ -187,7 +190,7 @@ def course_start_datetime_text(start_date, advertised_start, format_string, uget return _('TBD') -def course_end_datetime_text(end_date, format_string, strftime_localized): +def course_end_datetime_text(end_date, format_string, time_zone, strftime_localized): """ Returns a formatted string for a course's end date or datetime. @@ -196,11 +199,12 @@ def course_end_datetime_text(end_date, format_string, strftime_localized): Arguments: end_date (datetime): the end datetime of a course format_string (str): the date format type, as passed to strftime + time_zone (pytz time zone): the time zone to convert to strftime_localized ((datetime, str) -> str): a localized string formatting function """ return ( - _datetime_to_string(end_date, format_string, strftime_localized) if end_date is not None + _datetime_to_string(end_date, format_string, time_zone, strftime_localized) if end_date is not None else '' ) @@ -256,10 +260,10 @@ def sorting_dates(start, advertised_start, announcement): try: start = dateutil.parser.parse(advertised_start) if start.tzinfo is None: - start = start.replace(tzinfo=UTC()) + start = start.replace(tzinfo=utc) except (ValueError, AttributeError): start = start - now = datetime.now(UTC()) + now = datetime.now(utc) return announcement, start, now diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index e0d6dc61c7c9..6e8c7c4b4d88 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -5,6 +5,7 @@ from cStringIO import StringIO from lxml import etree from path import Path as path +from pytz import utc import requests from datetime import datetime from lazy import lazy @@ -21,7 +22,6 @@ from xblock.core import XBlock from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float from .fields import Date -from django.utils.timezone import UTC log = logging.getLogger(__name__) @@ -109,7 +109,7 @@ def table_of_contents(self): # see if we already fetched this if toc_url in _cached_toc: (table_of_contents, timestamp) = _cached_toc[toc_url] - age = datetime.now(UTC) - timestamp + age = datetime.now(utc) - timestamp # expire every 10 minutes if age.seconds < 600: return table_of_contents @@ -1038,7 +1038,7 @@ def has_terminated(self): if self.terminate_start is None: return False - return datetime.now(UTC()) > self.terminate_start + return datetime.now(utc) > self.terminate_start def is_course_deadline(self): """ @@ -1048,7 +1048,7 @@ def is_course_deadline(self): if self.deadline_start is None: return False - return datetime.now(UTC()) > self.deadline_start + return datetime.now(utc) > self.deadline_start def may_certify(self): """ @@ -1299,16 +1299,17 @@ def id(self): """Return the course_id for this course""" return self.location.course_key - def start_datetime_text(self, format_string="SHORT_DATE"): + def start_datetime_text(self, format_string="SHORT_DATE", time_zone=utc): """ - Returns the desired text corresponding the course's start date and time in UTC. Prefers .advertised_start, - then falls back to .start + Returns the desired text corresponding the course's start date and time in specified time zone, defaulted + to UTC. Prefers .advertised_start, then falls back to .start """ i18n = self.runtime.service(self, "i18n") return course_metadata_utils.course_start_datetime_text( self.start, self.advertised_start, format_string, + time_zone, i18n.ugettext, i18n.strftime ) @@ -1324,13 +1325,14 @@ def start_date_is_still_default(self): self.advertised_start ) - def end_datetime_text(self, format_string="SHORT_DATE"): + def end_datetime_text(self, format_string="SHORT_DATE", time_zone=utc): """ Returns the end date or date_time for the course formatted as a string. """ return course_metadata_utils.course_end_datetime_text( self.end, format_string, + time_zone, self.runtime.service(self, "i18n").strftime ) @@ -1365,7 +1367,7 @@ def forum_posts_allowed(self): setting """ blackouts = self.get_discussion_blackout_datetimes() - now = datetime.now(UTC()) + now = datetime.now(utc) for blackout in blackouts: if blackout["start"] <= now <= blackout["end"]: return False @@ -1493,4 +1495,57 @@ def can_toggle_course_pacing(self): Returns: bool: False if the course has already started, True otherwise. """ - return datetime.now(UTC()) <= self.start + return datetime.now(utc) <= self.start + + +class CourseSummary(object): + """ + A lightweight course summary class, which constructs split/mongo course summary without loading + the course. It is used at cms for listing courses to global staff user. + """ + course_info_fields = ['display_name', 'display_coursenumber', 'display_organization'] + + def __init__(self, course_locator, display_name=u"Empty", display_coursenumber=None, display_organization=None): + """ + Initialize and construct course summary + + Arguments: + course_locator (CourseLocator): CourseLocator object of the course. + + display_name (unicode): display name of the course. When you create a course from console, display_name + isn't set (course block has no key `display_name`). "Empty" name is returned when we load the course. + If `display_name` isn't present in the course block, use the `Empty` as default display name. + We can set None as a display_name in Course Advance Settings; Do not use "Empty" when display_name is + set to None. + + display_coursenumber (unicode|None): Course number that is specified & appears in the courseware + + display_organization (unicode|None): Course organization that is specified & appears in the courseware + + """ + self.display_coursenumber = display_coursenumber + self.display_organization = display_organization + self.display_name = display_name + + self.id = course_locator # pylint: disable=invalid-name + self.location = course_locator.make_usage_key('course', 'course') + + @property + def display_org_with_default(self): + """ + Return a display organization if it has been specified, otherwise return the 'org' that + is in the location + """ + if self.display_organization: + return self.display_organization + return self.location.org + + @property + def display_number_with_default(self): + """ + Return a display course number if it has been specified, otherwise return the 'course' that + is in the location + """ + if self.display_coursenumber: + return self.display_coursenumber + return self.location.course diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 2c6dd67d7c73..92e134fa5fe4 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -84,6 +84,34 @@ class @Problem @el.data('progress_status', response.progress_status) @el.data('progress_detail', response.progress_detail) @el.trigger('progressChanged') + + # update restricted subsections list + if typeof response.restricted_list isnt 'undefined' + $('.progress_restriction_info .restricted').data('list', response.restricted_list) + + # remove restricted-chapter classes from chapters not restricted + if typeof response.restricted_chapters isnt 'undefined' + $('#modal-accordion .chapter-wrapper').removeClass('restricted-chapter') + $('#modal-accordion .chapter-wrapper .button-chapter').removeClass('restricted-chapter') + $('#modal-accordion .chapter-wrapper .chapter-content-container .chapter-menu').removeClass('restricted-chapter') + $('.course-index .accordion .course-navigation .button-chapter').removeClass('restricted-chapter') + $('.course-index .accordion .course-navigation .chapter-content-container .chapter-menu').removeClass('restricted-chapter') + for chapter_idx in response.restricted_chapters + $($('#modal-accordion .chapter-wrapper')[parseInt(chapter_idx, 10)]).addClass('restricted-chapter') + $($('#modal-accordion .chapter-wrapper .button-chapter')[parseInt(chapter_idx, 10)]).addClass('restricted-chapter') + $($('#modal-accordion .chapter-wrapper .chapter-content-container .chapter-menu')[parseInt(chapter_idx, 10)]).addClass('restricted-chapter') + $($('.course-index .accordion .course-navigation .button-chapter')[parseInt(chapter_idx, 10)]).addClass('restricted-chapter') + $($('.course-index .accordion .course-navigation .chapter-content-container .chapter-menu')[parseInt(chapter_idx, 10)]).addClass('restricted-chapter') + + # remove restricted-section classes from sections not restricted + if typeof response.restricted_sections isnt 'undefined' + $('#modal-accordion .chapter-wrapper .chapter-content-container .chapter-menu .menu-item').removeClass('restricted-section') + $('.course-index .accordion .course-navigation .chapter-content-container .chapter-menu .menu-item').removeClass('restricted-section') + for chapter_idx, sections of response.restricted_sections + for idx in sections + $($('#modal-accordion .chapter-wrapper .chapter-content-container .chapter-menu')[parseInt(chapter_idx, 10)]).children(".menu-item:nth-child(#{idx})").addClass('restricted-section') + $($('.course-index .accordion .course-navigation .chapter-content-container .chapter-menu')[parseInt(chapter_idx, 10)]).children(".menu-item:nth-child(#{idx})").addClass('restricted-section') + @renderProgressState() forceUpdate: (response) => diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index 959ed7534337..5147ad0a69b8 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -102,6 +102,17 @@ class @Sequence @mark_active new_position current_tab = @contents.eq(new_position - 1) + if $('.progress_restriction_info').length > 0 + restricted_list = $('.progress_restriction_info .restricted').data('list') + if restricted_list.indexOf(new_position - 1) >= 0 + current_tab = $('
      ').text( + '
      ' + + gettext( + 'You need to make correct answers for the some problems in the before quiz.' + ) + + '
      ' + ) + current_tab.addClass('xblock') @content_container.html(current_tab.text()).attr("aria-labelledby", current_tab.attr("aria-labelledby")) XBlock.initializeBlocks(@content_container, @requestToken) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 99dcd73f9e6e..0775b2eef026 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -11,7 +11,7 @@ from lxml import etree from xblock.core import XBlock -from xblock.fields import Integer, Scope, Boolean +from xblock.fields import Integer, Scope, Boolean, Dict from xblock.fragment import Fragment from .exceptions import NotFoundError @@ -81,6 +81,14 @@ class SequenceFields(object): scope=Scope.settings ) + progress_restriction = Dict( + help=_("Settings for progress restriction"), + default={ + "type": "No Restriction", + }, + scope=Scope.settings + ) + class ProctoringFields(object): """ diff --git a/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py index 5e8d4ec20367..d5f1505cd02c 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py +++ b/common/lib/xmodule/xmodule/tests/test_course_metadata_utils.py @@ -5,8 +5,7 @@ from datetime import timedelta, datetime from unittest import TestCase -from django.utils.timezone import UTC - +from pytz import timezone, utc from xmodule.course_metadata_utils import ( clean_course_key, url_name_for_course_location, @@ -28,7 +27,7 @@ ) -_TODAY = datetime.now(UTC()) +_TODAY = datetime.now(utc) _LAST_MONTH = _TODAY - timedelta(days=30) _LAST_WEEK = _TODAY - timedelta(days=7) _NEXT_WEEK = _TODAY + timedelta(days=7) @@ -104,14 +103,18 @@ def mock_strftime_localized(date_time, format_string): else: raise ValueError("Invalid format string :" + format_string) - def nop_gettext(text): + def noop_gettext(text): """Dummy implementation of gettext, so we don't need Django.""" return text - test_datetime = datetime(1945, 02, 06, 04, 20, 00, tzinfo=UTC()) + test_datetime = datetime(1945, 2, 6, 4, 20, 00, tzinfo=utc) advertised_start_parsable = "2038-01-19 03:14:07" advertised_start_bad_date = "215-01-01 10:10:10" advertised_start_unparsable = "This coming fall" + time_zone_normal_parsable = "2016-03-27 00:59:00" + time_zone_normal_datetime = datetime(2016, 3, 27, 00, 59, 00, tzinfo=utc) + time_zone_daylight_parsable = "2016-03-27 01:00:00" + time_zone_daylight_datetime = datetime(2016, 3, 27, 1, 00, 00, tzinfo=utc) FunctionTest = namedtuple('FunctionTest', 'function scenarios') # pylint: disable=invalid-name TestScenario = namedtuple('TestScenario', 'arguments expected_return') # pylint: disable=invalid-name @@ -161,48 +164,73 @@ def nop_gettext(text): # Test parsable advertised start date. # Expect start datetime to be parsed and formatted back into a string. TestScenario( - (DEFAULT_START_DATE, advertised_start_parsable, 'DATE_TIME', nop_gettext, mock_strftime_localized), + (DEFAULT_START_DATE, advertised_start_parsable, 'DATE_TIME', + utc, noop_gettext, mock_strftime_localized), mock_strftime_localized(Date().from_json(advertised_start_parsable), 'DATE_TIME') + " UTC" ), # Test un-parsable advertised start date. # Expect date parsing to throw a ValueError, and the advertised # start to be returned in Title Case. TestScenario( - (test_datetime, advertised_start_unparsable, 'DATE_TIME', nop_gettext, mock_strftime_localized), + (test_datetime, advertised_start_unparsable, 'DATE_TIME', + utc, noop_gettext, mock_strftime_localized), advertised_start_unparsable.title() ), # Test parsable advertised start date from before January 1, 1900. # Expect mock_strftime_localized to throw a ValueError, and the # advertised start to be returned in Title Case. TestScenario( - (test_datetime, advertised_start_bad_date, 'DATE_TIME', nop_gettext, mock_strftime_localized), + (test_datetime, advertised_start_bad_date, 'DATE_TIME', + utc, noop_gettext, mock_strftime_localized), advertised_start_bad_date.title() ), # Test without advertised start date, but with a set start datetime. # Expect formatted datetime to be returned. TestScenario( - (test_datetime, None, 'SHORT_DATE', nop_gettext, mock_strftime_localized), + (test_datetime, None, 'SHORT_DATE', utc, noop_gettext, mock_strftime_localized), mock_strftime_localized(test_datetime, 'SHORT_DATE') ), # Test without advertised start date and with default start datetime. # Expect TBD to be returned. TestScenario( - (DEFAULT_START_DATE, None, 'SHORT_DATE', nop_gettext, mock_strftime_localized), + (DEFAULT_START_DATE, None, 'SHORT_DATE', utc, noop_gettext, mock_strftime_localized), 'TBD' + ), + # Test correctly formatted start datetime is returned during normal daylight hours + TestScenario( + (DEFAULT_START_DATE, time_zone_normal_parsable, 'DATE_TIME', + timezone('Europe/Paris'), noop_gettext, mock_strftime_localized), + "DATE_TIME " + "2016-03-27 01:59:00 CET" + ), + # Test correctly formatted start datetime is returned during daylight savings hours + TestScenario( + (DEFAULT_START_DATE, time_zone_daylight_parsable, 'DATE_TIME', + timezone('Europe/Paris'), noop_gettext, mock_strftime_localized), + "DATE_TIME " + "2016-03-27 03:00:00 CEST" ) ]), FunctionTest(course_end_datetime_text, [ # Test with a set end datetime. # Expect formatted datetime to be returned. TestScenario( - (test_datetime, 'TIME', mock_strftime_localized), + (test_datetime, 'TIME', utc, mock_strftime_localized), mock_strftime_localized(test_datetime, 'TIME') + " UTC" ), # Test with default end datetime. # Expect empty string to be returned. TestScenario( - (None, 'TIME', mock_strftime_localized), + (None, 'TIME', utc, mock_strftime_localized), "" + ), + # Test correctly formatted end datetime is returned during normal daylight hours + TestScenario( + (time_zone_normal_datetime, 'TIME', timezone('Europe/Paris'), mock_strftime_localized), + "TIME " + "2016-03-27 01:59:00 CET" + ), + # Test correctly formatted end datetime is returned during daylight savings hours + TestScenario( + (time_zone_daylight_datetime, 'TIME', timezone('Europe/Paris'), mock_strftime_localized), + "TIME " + "2016-03-27 03:00:00 CEST" ) ]), FunctionTest(may_certify_for_course, [ diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 0c4e419bb5cf..2efa043b9a4b 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -1,25 +1,25 @@ +"""Tests the course modules and their functions""" +import ddt import unittest from datetime import datetime, timedelta +import itertools from fs.memoryfs import MemoryFS - from mock import Mock, patch -import itertools - +from pytz import timezone, utc from xblock.runtime import KvsFieldData, DictKeyValueStore import xmodule.course_module from xmodule.modulestore.xml import ImportSystem, XMLModuleStore from opaque_keys.edx.locations import SlashSeparatedCourseKey -from django.utils.timezone import UTC ORG = 'test_org' COURSE = 'test_course' -NOW = datetime.strptime('2013-01-01T01:00:00', '%Y-%m-%dT%H:%M:00').replace(tzinfo=UTC()) +NOW = datetime.strptime('2013-01-01T01:00:00', '%Y-%m-%dT%H:%M:00').replace(tzinfo=utc) -_TODAY = datetime.now(UTC()) +_TODAY = datetime.now(utc) _LAST_WEEK = _TODAY - timedelta(days=7) _NEXT_WEEK = _TODAY + timedelta(days=7) @@ -28,7 +28,7 @@ class CourseFieldsTestCase(unittest.TestCase): def test_default_start_date(self): self.assertEqual( xmodule.course_module.CourseFields.start.default, - datetime(2030, 1, 1, tzinfo=UTC()) + datetime(2030, 1, 1, tzinfo=utc) ) @@ -142,6 +142,7 @@ def test_may_certify(self): self.assertFalse(self.future_noshow_certs.may_certify()) +@ddt.ddt class IsNewCourseTestCase(unittest.TestCase): """Make sure the property is_new works on courses""" @@ -224,6 +225,20 @@ def test_start_date_time_text(self, gmtime_mock): print "Checking start=%s advertised=%s" % (setting[0], setting[1]) self.assertEqual(course.start_datetime_text("DATE_TIME"), setting[4]) + @ddt.data(("2015-11-01T08:59", 'Nov 01, 2015', u'Nov 01, 2015 at 01:59 PDT'), + ("2015-11-01T09:00", 'Nov 01, 2015', u'Nov 01, 2015 at 01:00 PST')) + @ddt.unpack + def test_start_date_time_zone(self, course_date, expected_short_date, expected_date_time): + """ + Test that start datetime text correctly formats datetimes + for normal daylight hours and daylight savings hours + """ + time_zone = timezone('America/Los_Angeles') + + course = get_dummy_course(start=course_date, advertised_start=course_date) + self.assertEqual(course.start_datetime_text(time_zone=time_zone), expected_short_date) + self.assertEqual(course.start_datetime_text("DATE_TIME", time_zone), expected_date_time) + def test_start_date_is_default(self): for s in self.start_advertised_settings: d = get_dummy_course(start=s[0], advertised_start=s[1]) @@ -277,6 +292,20 @@ def test_end_date_time_text(self): course = get_dummy_course('2012-12-02T12:00', end='2014-9-04T12:00') self.assertEqual('Sep 04, 2014 at 12:00 UTC', course.end_datetime_text("DATE_TIME")) + @ddt.data(("2015-11-01T08:59", 'Nov 01, 2015', u'Nov 01, 2015 at 01:59 PDT'), + ("2015-11-01T09:00", 'Nov 01, 2015', u'Nov 01, 2015 at 01:00 PST')) + @ddt.unpack + def test_end_date_time_zone(self, course_date, expected_short_date, expected_date_time): + """ + Test that end datetime text correctly formats datetimes + for normal daylight hours and daylight savings hours + """ + time_zone = timezone('America/Los_Angeles') + course = get_dummy_course(course_date, end=course_date) + + self.assertEqual(course.end_datetime_text(time_zone=time_zone), expected_short_date) + self.assertEqual(course.end_datetime_text("DATE_TIME", time_zone), expected_date_time) + class DiscussionTopicsTestCase(unittest.TestCase): def test_default_discussion_topics(self): diff --git a/common/test/acceptance/pages/lms/ga_instructor_dashboard.py b/common/test/acceptance/pages/lms/ga_instructor_dashboard.py index f480f9168e74..59ab44b461b6 100644 --- a/common/test/acceptance/pages/lms/ga_instructor_dashboard.py +++ b/common/test/acceptance/pages/lms/ga_instructor_dashboard.py @@ -101,3 +101,9 @@ def add_role(self, role_name, member): self.q(css='div[data-rolename="{}"] input.add-field'.format(role_name)).fill(member) self.q(css='div[data-rolename="{}"] input.add'.format(role_name)).click() self.wait_for_ajax() + + def add_role_by_display_name(self, role_name, member): + self.select_role(role_name) + self.q(css='div[data-display-name="{}"] input.add-field'.format(role_name)).fill(member) + self.q(css='div[data-display-name="{}"] input.add'.format(role_name)).click() + self.wait_for_ajax() diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py index 675e76e56c77..0cfa2f233505 100644 --- a/common/test/acceptance/pages/studio/overview.py +++ b/common/test/acceptance/pages/studio/overview.py @@ -527,6 +527,7 @@ def change_problem_release_date_in_studio(self): """ self.q(css=".subsection-header-actions .configure-button").first.click() self.q(css="#start_date").fill("01/01/2030") + self.q(css="#start_time").click() self.q(css=".action-save").first.click() self.wait_for_ajax() diff --git a/common/test/acceptance/tests/ga_helpers.py b/common/test/acceptance/tests/ga_helpers.py index 8b2936cfdd51..8c04e75ca34f 100644 --- a/common/test/acceptance/tests/ga_helpers.py +++ b/common/test/acceptance/tests/ga_helpers.py @@ -32,6 +32,16 @@ 'password': 'GaOldCourse1', 'email': 'gaoldcourseviewer@example.com', } +GA_GLOBAL_COURSE_CREATOR_USER_INFO = { + 'username': 'Ga_Global_Course_Creator', + 'password': 'GaGlobalCourseCreator1', + 'email': 'ga_global_course_creator@example.com', +} +GA_COURSE_SCORER_USER_INFO = { + 'username': 'Ga_Course_Scorer', + 'password': 'Ga_Course_Scorer1', + 'email': 'ga_course_scorer@example.com', +} @unittest.skipUnless(os.environ.get('ENABLE_BOKCHOY_GA'), "Test only valid in gacco") diff --git a/common/test/acceptance/tests/ga_role_helpers.py b/common/test/acceptance/tests/ga_role_helpers.py new file mode 100644 index 000000000000..e763a21c4007 --- /dev/null +++ b/common/test/acceptance/tests/ga_role_helpers.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" +Test helper functions and base classes. +""" +from .ga_helpers import GaccoTestMixin, SUPER_USER_INFO, GA_COURSE_SCORER_USER_INFO, GA_GLOBAL_COURSE_CREATOR_USER_INFO +from ..pages.lms.auto_auth import AutoAuthPage +from ..pages.lms.ga_instructor_dashboard import MembershipPageMemberListSection, InstructorDashboardPage + + +class GaccoTestRoleMixin(GaccoTestMixin): + def add_course_role(self, course_id, role_name, member): + self.switch_to_user(SUPER_USER_INFO) + instructor_dashboard_page = InstructorDashboardPage(self.browser, course_id).visit() + instructor_dashboard_page.select_membership() + MembershipPageMemberListSection(self.browser).wait_for_page().add_role_by_display_name(role_name, member) + self.logout() + + def auto_auth_with_ga_global_course_creator(self, course_id): + # Auto-auth register for the course + AutoAuthPage( + self.browser, + username=GA_GLOBAL_COURSE_CREATOR_USER_INFO['username'], + password=GA_GLOBAL_COURSE_CREATOR_USER_INFO['password'], + email=GA_GLOBAL_COURSE_CREATOR_USER_INFO['email'], + course_id=course_id + ).visit() + return GA_GLOBAL_COURSE_CREATOR_USER_INFO + + def auto_auth_with_ga_course_scorer(self, course_id): + self.add_course_role(course_id, 'Course Scorer', GA_COURSE_SCORER_USER_INFO['email']) + AutoAuthPage( + self.browser, + username=GA_COURSE_SCORER_USER_INFO['username'], + password=GA_COURSE_SCORER_USER_INFO['password'], + email=GA_COURSE_SCORER_USER_INFO['email'], + course_id=course_id + ).visit() + return GA_COURSE_SCORER_USER_INFO diff --git a/common/test/acceptance/tests/lms/test_ga_courseware.py b/common/test/acceptance/tests/lms/test_ga_courseware.py new file mode 100644 index 000000000000..64416b554585 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_ga_courseware.py @@ -0,0 +1,312 @@ +""" +End-to-end tests for the LMS. +""" +import ddt + +from ..ga_helpers import GaccoTestMixin, SUPER_USER_INFO +from ..helpers import UniqueCourseTest +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.lms.course_nav import CourseNavPage +from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage +from ...pages.lms.ga_django_admin import DjangoAdminPage +from ...pages.lms.problem import ProblemPage +from ...pages.studio.ga_overview import CourseOutlinePage +from textwrap import dedent + + +@ddt.ddt +class CoursewareProgressRestrictionTest(UniqueCourseTest, GaccoTestMixin): + + def setUp(self): + super(CoursewareProgressRestrictionTest, self).setUp() + + self.user_info = { + 'username': 'STUDENT_TESTER_2147', + 'password': 'STUDENT_PASS', + 'email': 'student2147@example.com' + } + self.user_info_staff = { + 'username': 'STAFF_TESTER_2147', + 'password': 'STAFF_PASS', + 'email': 'staff2147@example.com' + } + + self.course_info = { + 'org': 'test_org_00003', + 'number': self._testMethodName, + 'run': 'test_run_00003', + 'display_name': 'Progress Restriction Course' + } + + self.course_fixture = CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'] + ) + + problem1_xml = dedent(""" + +

      Answer is A

      + + + +
      + """) + problem2_xml = dedent(""" + +

      Answer is B

      + + + +
      + """) + self.course_fixture.add_children( + XBlockFixtureDesc('chapter', '1').add_children( + XBlockFixtureDesc('sequential', '1.1').add_children( + XBlockFixtureDesc('vertical', '1.1.1'), + XBlockFixtureDesc('vertical', '1.1.2').add_children( + XBlockFixtureDesc('problem', 'Test Problem 1', data=problem1_xml), + XBlockFixtureDesc('problem', 'Test Problem 2', data=problem2_xml), + ), + XBlockFixtureDesc('vertical', '1.1.3').add_children( + XBlockFixtureDesc('html', 'Test HTML 1', data='Test HTML 1') + ) + ), + XBlockFixtureDesc('sequential', '1.2').add_children( + XBlockFixtureDesc('vertical', '1.2.1').add_children( + XBlockFixtureDesc('problem', 'Test Problem 3') + ), + XBlockFixtureDesc('vertical', '1.2.2').add_children( + XBlockFixtureDesc('problem', 'Test Problem 4') + ) + ) + ), + XBlockFixtureDesc('chapter', '2').add_children( + XBlockFixtureDesc('sequential', '2.1').add_children( + XBlockFixtureDesc('vertical', '2.1.1').add_children( + XBlockFixtureDesc('html', 'Test HTML 2', data='Test HTML 2') + ) + ), + ) + ) + self.course_fixture.install() + + self.course_outline_page = CourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + + def _switch_to_user(self, user_info, course_id=None, staff=None): + self.logout() + AutoAuthPage( + self.browser, + username=user_info['username'], + password=user_info['password'], + email=user_info['email'], + course_id=course_id, + staff=staff + ).visit() + return user_info + + def _set_progress_restriction(self): + self._switch_to_user(SUPER_USER_INFO) + + DjangoAdminPage(self.browser).visit().click_add('ga_optional', 'courseoptionalconfiguration').input({ + 'enabled': True, + 'key': 'progress-restriction-settings', + 'course_key': self.course_id, + }).save() + + def _set_passing_mark(self, section_at, subsection_at, unit_at, passing_mark): + self._set_progress_restriction() + self._switch_to_user(self.user_info_staff, course_id=self.course_id, staff=True) + + self.course_outline_page.visit() + self.course_outline_page.expand_all_subsections() + unit = self.course_outline_page.section_at(section_at).subsection_at(subsection_at).unit_at(unit_at) + modal = unit.edit() + fields = modal.find_css('li.field-progress-restriction-passing-mark span.progress-restriction-percentage input') + fields.fill(passing_mark) + modal.save() + + def _get_sequential_tab_page(self, position): + subsection_url = self.courseware_page.get_active_subsection_url() + url_part_list = subsection_url.split('/') + course_id = url_part_list[4] + chapter_id = url_part_list[-3] + subsection_id = url_part_list[-2] + + return CoursewareSequentialTabPage( + self.browser, + course_id=course_id, + chapter=chapter_id, + subsection=subsection_id, + position=position + ) + + def test_accessibility_vertical_block(self): + self._set_passing_mark(0, 0, 1, 100) + + # go to courseware + self._switch_to_user(self.user_info, course_id=self.course_id, staff=False) + + self.courseware_page.visit() + + course_nav = CourseNavPage(self.browser) + course_nav.go_to_section('1', '1.1') + + # go to next unit of the unit that is set passing mark + # expecting not to be able to access + tab3_page = self._get_sequential_tab_page(3).visit() + self.assertIn('You need to make correct answers for the some problems in the before quiz.', + tab3_page.get_selected_tab_content()) + + # send correct answer to 1/2 + tab2_page = self._get_sequential_tab_page(2).visit() + problem_page = ProblemPage(self.browser) + problem_page.fill_answer('A', 0) + problem_page.click_check() + + # go to next unit of the unit that is set passing mark + # expecting not to be able to access yet + tab3_page.visit() + self.assertIn('You need to make correct answers for the some problems in the before quiz.', + tab3_page.get_selected_tab_content()) + + # send correct answer to 2/2 + tab2_page.visit() + problem_page.fill_answer('B', 1) + problem_page.click_check() + + # go to next unit of the unit that is set passing mark + # expecting to be able to access + tab3_page.visit() + self.assertNotIn('You need to make correct answers for the some problems in the before quiz.', + tab3_page.get_selected_tab_content()) + self.assertIn('Test HTML 1', + tab3_page.get_selected_tab_content()) + + # resend incorrect answer + tab2_page.visit() + problem_page.fill_answer('C', 1) + problem_page.click_check() + + # go to next unit of the unit that is set passing mark + # expecting to be able to access + tab3_page.visit() + self.assertNotIn('You need to make correct answers for the some problems in the before quiz.', + tab3_page.get_selected_tab_content()) + self.assertIn('Test HTML 1', + tab3_page.get_selected_tab_content()) + + def test_can_access_previous_vertical_block(self): + self._set_passing_mark(0, 0, 1, 100) + + # go to courseware + self._switch_to_user(self.user_info, course_id=self.course_id, staff=False) + + self.courseware_page.visit() + + course_nav = CourseNavPage(self.browser) + course_nav.go_to_section('1', '1.1') + + # go to previous unit of the unit that is set passing mark + # expecting to be able to access + tab_page = self._get_sequential_tab_page(0).visit() + self.assertNotIn('You need to make correct answers for the some problems in the before quiz.', + tab_page.get_selected_tab_content()) + + @ddt.data( + ('1', '1.2', 'Test Problem 3'), + ('2', '2.1', 'Test HTML 2') + ) + @ddt.unpack + def test_accessibility_sequential_block(self, + access_chapter_name, + access_sequential_name, + unit_content): + self._set_passing_mark(0, 0, 1, 100) + + # go to courseware + self._switch_to_user(self.user_info, course_id=self.course_id, staff=False) + + self.courseware_page.visit() + + course_nav = CourseNavPage(self.browser) + + # go to the following subsections of the unit that is set passing mark + # expecting not to be able to access + course_nav.go_to_section(access_chapter_name, access_sequential_name) + + tab_page = self._get_sequential_tab_page(1).visit() + self.assertIn('You need to make correct answers for the some problems in the before quiz.', + tab_page.get_selected_tab_content()) + + # send correct answer + course_nav.go_to_section('1', '1.1') + self._get_sequential_tab_page(2).visit() + problem_page = ProblemPage(self.browser) + problem_page.fill_answer('A', 0) + problem_page.fill_answer('B', 1) + problem_page.click_check() + + # go to the following subsections of the unit that is set passing mark + # expecting to be able to access + course_nav.go_to_section(access_chapter_name, access_sequential_name) + tab_page = self._get_sequential_tab_page(1).visit() + self.assertNotIn('You need to make correct answers for the some problems in the before quiz.', + tab_page.get_selected_tab_content()) + self.assertIn(unit_content, tab_page.get_selected_tab_content()) + + def test_display_restricted_on_sidebar(self): + self._set_passing_mark(0, 0, 1, 100) + + # go to courseware + self._switch_to_user(self.user_info, course_id=self.course_id, staff=False) + + self.courseware_page.visit() + + chapters = self.courseware_page.q(css='.course-index .accordion .course-navigation .chapter') + self.assertEqual(len(chapters), 2) + + classes_of_chapters = chapters.attrs('class') + self.assertNotIn('restricted-chapter', classes_of_chapters[0].split(' ')) + self.assertIn('restricted-chapter', classes_of_chapters[1].split(' ')) + + sections = self.courseware_page.q(css='.course-index .accordion .course-navigation .chapter-content-container .chapter-menu .menu-item') + self.assertEqual(len(sections), 3) + + classes_of_sections = sections.attrs('class') + self.assertNotIn('restricted-section', classes_of_sections[0].split(' ')) + self.assertIn('restricted-section', classes_of_sections[1].split(' ')) + self.assertIn('restricted-section', classes_of_sections[2].split(' ')) + + # send correct answer + course_nav = CourseNavPage(self.browser) + course_nav.go_to_section('1', '1.1') + self._get_sequential_tab_page(2).visit() + problem_page = ProblemPage(self.browser) + problem_page.fill_answer('A', 0) + problem_page.fill_answer('B', 1) + problem_page.click_check() + + chapters = self.courseware_page.q(css='.course-index .accordion .course-navigation .chapter') + self.assertEqual(len(chapters), 2) + + classes_of_chapters = chapters.attrs('class') + self.assertNotIn('restricted-chapter', classes_of_chapters[0].split(' ')) + self.assertNotIn('restricted-chapter', classes_of_chapters[1].split(' ')) + + sections = self.courseware_page.q(css='.course-index .accordion .course-navigation .chapter-content-container .chapter-menu .menu-item') + self.assertEqual(len(sections), 3) + + classes_of_sections = sections.attrs('class') + self.assertNotIn('restricted-section', classes_of_sections[0].split(' ')) + self.assertNotIn('restricted-section', classes_of_sections[1].split(' ')) + self.assertNotIn('restricted-section', classes_of_sections[2].split(' ')) diff --git a/common/test/acceptance/tests/lms/test_ga_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_ga_instructor_dashboard.py index 927c76d1a2fe..127d1a45b2fd 100644 --- a/common/test/acceptance/tests/lms/test_ga_instructor_dashboard.py +++ b/common/test/acceptance/tests/lms/test_ga_instructor_dashboard.py @@ -16,12 +16,12 @@ from ...pages.lms.ga_instructor_dashboard import InstructorDashboardPage from ...pages.lms.login_and_register import CombinedLoginAndRegisterPage from ...tests.biz import PLATFORMER_USER_INFO, A_COMPANY, GaccoBizTestMixin -from ..ga_helpers import GaccoTestMixin +from ..ga_role_helpers import GaccoTestRoleMixin from ..helpers import EventsTestMixin from lms.envs.ga_bok_choy import EMAIL_FILE_PATH -class GaccoLmsInstructorDashboardTestMixin(GaccoTestMixin): +class GaccoLmsInstructorDashboardTestMixin(GaccoTestRoleMixin): def _create_user(self, user, course_key, staff): AutoAuthPage( self.browser, @@ -71,18 +71,22 @@ def setUp(self): username_global_staff = 'test_globalstaff_' + self.unique_id[0:6] username_course_staff = 'test_coursestaff_' + self.unique_id[0:6] username_course_instructor = 'test_courseinstructor_' + self.unique_id[0:6] + username_course_ga_course_scorer = 'test_gacoursescorer_' + self.unique_id[0:6] self.user_global_staff = {'username': username_global_staff, 'email': username_global_staff + '@example.com'} self.user_course_staff = {'username': username_course_staff, 'email': username_course_staff + '@example.com'} self.user_course_instructor = {'username': username_course_instructor, 'email': username_course_instructor + '@example.com'} + self.user_course_ga_course_scorer = {'username': username_course_ga_course_scorer, 'email': username_course_ga_course_scorer + '@example.com'} self._create_user(self.user_global_staff, self.course_key, True) self._create_user(self.user_course_staff, self.course_key, False) self._create_user(self.user_course_instructor, self.course_key, False) + self._create_user(self.user_course_ga_course_scorer, self.course_key, False) # Add course team members CourseTeamFixture(self.course_key, self.user_course_staff['email'], False).install() CourseTeamFixture(self.course_key, self.user_course_instructor['email'], True).install() + self.add_course_role(self.course_key, 'Course Scorer', self.user_course_ga_course_scorer['email']) # Initialize pages. self.dashboard_page = DashboardPage(self.browser) @@ -152,6 +156,20 @@ def test_displayed_optout_checkbox_only_global_staff(self): # Logout self.logout_page.visit() + ## login as ga lms course staff ## + self._login(self.user_course_ga_course_scorer['email']) + + # Visit instructor dashboard and send email section + self.instructor_dashboard_page.visit() + send_email_section = self.instructor_dashboard_page.select_send_email() + + # Select all + send_email_section.select_send_to('all') + self.assertFalse(send_email_section.is_visible_optout_container()) + + # Logout + self.logout_page.visit() + def test_send_to_all_include_optout(self): """ Scenario: @@ -189,7 +207,8 @@ def test_send_to_all_include_optout(self): self.assertItemsEqual([ self.user_global_staff['email'], self.user_course_instructor['email'], - self.user_course_staff['email'] + self.user_course_staff['email'], + self.user_course_ga_course_scorer['email'], ], [message['to_addresses'] for message in messages]) self.email_client.clear_messages() @@ -208,6 +227,7 @@ def test_send_to_all_include_optout(self): self.user_global_staff['email'], self.user_course_instructor['email'], self.user_course_staff['email'], + self.user_course_ga_course_scorer['email'], user_optout['email'] ], [message['to_addresses'] for message in messages]) @@ -313,7 +333,7 @@ def _refund_ticket(order_id, amount, tax, currency='JPN'): _expected_emails = [ user['email'] for user in users - ] + [self.user_course_instructor['email'], self.user_course_staff['email']] + ] + [self.user_course_instructor['email'], self.user_course_staff['email'], self.user_course_ga_course_scorer['email']] # this filtering is the process for excluding users to be automatically created by fixture. messages = filter(lambda msg: msg['to_addresses'].startswith('test_'), self.email_client.get_messages()) diff --git a/common/test/acceptance/tests/lms/test_ga_lms.py b/common/test/acceptance/tests/lms/test_ga_lms.py new file mode 100644 index 000000000000..8d1ff8710d44 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_ga_lms.py @@ -0,0 +1,948 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for the LMS. +""" + +from textwrap import dedent +from nose.plugins.attrib import attr + +from bok_choy.promise import EmptyPromise +from ..ga_helpers import GA_COURSE_SCORER_USER_INFO, GA_GLOBAL_COURSE_CREATOR_USER_INFO, GA_OLD_COURSE_VIEWER_USER_INFO +from ..ga_role_helpers import GaccoTestRoleMixin +from ..helpers import ( + UniqueCourseTest, + EventsTestMixin, + load_data_str, + generate_course_key, + select_option_by_value, + element_has_text +) +from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc +from ...pages.common.logout import LogoutPage +from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.course_info import CourseInfoPage +from ...pages.lms.course_nav import CourseNavPage +from ...pages.lms.course_wiki import CourseWikiPage, CourseWikiEditPage +from ...pages.lms.dashboard import DashboardPage +from ...pages.lms.problem import ProblemPage +from ...pages.lms.progress import ProgressPage +from ...pages.lms.tab_nav import TabNavPage +from ...pages.lms.video.video import VideoPage +from ...pages.studio.settings import SettingsPage + + +@attr('shard_1') +class CourseWikiTestWithGaGlobalCourseCreator(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests that verify the course wiki. + """ + def _auto_auth(self): + self.auto_auth_with_ga_global_course_creator(self.course_id) + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(CourseWikiTestWithGaGlobalCourseCreator, self).setUp() + + # self.course_info['number'] must be shorter since we are accessing the wiki. See TNL-1751 + self.course_info['number'] = self.unique_id[0:6] + + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.course_wiki_page = CourseWikiPage(self.browser, self.course_id) + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.course_wiki_edit_page = CourseWikiEditPage(self.browser, self.course_id, self.course_info) + self.tab_nav = TabNavPage(self.browser) + + CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ).install() + + # Auto-auth register for the course + self._auto_auth() + + # Access course wiki page + self.course_info_page.visit() + self.tab_nav.go_to_tab('Wiki') + + def _open_editor(self): + self.course_wiki_page.open_editor() + self.course_wiki_edit_page.wait_for_page() + + def test_edit_course_wiki(self): + """ + Wiki page by default is editable for students. + + After accessing the course wiki, + Replace the content of the default page + Confirm new content has been saved + + """ + content = "hello" + self._open_editor() + self.course_wiki_edit_page.replace_wiki_content(content) + self.course_wiki_edit_page.save_wiki_content() + actual_content = unicode(self.course_wiki_page.q(css='.wiki-article p').text[0]) + self.assertEqual(content, actual_content) + + +@attr('shard_1') +class CourseWikiTestWithGaCourseScorer(CourseWikiTestWithGaGlobalCourseCreator): + """ + Tests that verify the course wiki. + """ + def _auto_auth(self): + self.auto_auth_with_ga_course_scorer(self.course_id) + + +@attr('shard_1') +class HighLevelTabTestWithGaGlobalCourseCreator(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests that verify each of the high-level tabs available within a course. + """ + + def _auto_auth(self): + # Auto-auth register for the course + self.auto_auth_with_ga_global_course_creator(self.course_id) + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(HighLevelTabTestWithGaGlobalCourseCreator, self).setUp() + + # self.course_info['number'] must be shorter since we are accessing the wiki. See TNL-1751 + self.course_info['number'] = self.unique_id[0:6] + + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.progress_page = ProgressPage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + self.tab_nav = TabNavPage(self.browser) + self.video = VideoPage(self.browser) + + # Install a course with sections/problems, tabs, updates, and handouts + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + course_fix.add_update( + CourseUpdateDesc(date='January 29, 2014', content='Test course update1') + ) + + course_fix.add_handout('demoPDF.pdf') + + course_fix.add_children( + XBlockFixtureDesc('static_tab', 'Test Static Tab'), + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('problem', 'Test Problem 1', data=load_data_str('multiple_choice.xml')), + XBlockFixtureDesc('problem', 'Test Problem 2', data=load_data_str('formula_problem.xml')), + XBlockFixtureDesc('html', 'Test HTML'), + ) + ), + XBlockFixtureDesc('chapter', 'Test Section 2').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection 2'), + XBlockFixtureDesc('sequential', 'Test Subsection 3'), + ) + ).install() + + self._auto_auth() + + def test_course_info(self): + """ + Navigate to the course info page. + """ + + # Navigate to the course info page from the progress page + self.progress_page.visit() + self.tab_nav.go_to_tab('Course Info') + + # Expect just one update + self.assertEqual(self.course_info_page.num_updates, 1) + + # Expect a link to the demo handout pdf + handout_links = self.course_info_page.handout_links + self.assertEqual(len(handout_links), 1) + self.assertIn('demoPDF.pdf', handout_links[0]) + + def test_progress(self): + """ + Navigate to the progress page. + """ + # Navigate to the progress page from the info page + self.course_info_page.visit() + self.tab_nav.go_to_tab('Progress') + + # We haven't answered any problems yet, so assume scores are zero + # Only problems should have scores; so there should be 2 scores. + CHAPTER = 'Test Section' + SECTION = 'Test Subsection' + EXPECTED_SCORES = [(0, 3), (0, 1)] + + actual_scores = self.progress_page.scores(CHAPTER, SECTION) + self.assertEqual(actual_scores, EXPECTED_SCORES) + + def test_static_tab(self): + """ + Navigate to a static tab (course content) + """ + # From the course info page, navigate to the static tab + self.course_info_page.visit() + self.tab_nav.go_to_tab('Test Static Tab') + self.assertTrue(self.tab_nav.is_on_tab('Test Static Tab')) + + def test_wiki_tab_first_time(self): + """ + Navigate to the course wiki tab. When the wiki is accessed for + the first time, it is created on the fly. + """ + + course_wiki = CourseWikiPage(self.browser, self.course_id) + # From the course info page, navigate to the wiki tab + self.course_info_page.visit() + self.tab_nav.go_to_tab('Wiki') + self.assertTrue(self.tab_nav.is_on_tab('Wiki')) + + # Assert that a default wiki is created + expected_article_name = "{org}.{course_number}.{course_run}".format( + org=self.course_info['org'], + course_number=self.course_info['number'], + course_run=self.course_info['run'] + ) + self.assertEqual(expected_article_name, course_wiki.article_name) + + def test_courseware_nav(self): + """ + Navigate to a particular unit in the courseware. + """ + # Navigate to the courseware page from the info page + self.course_info_page.visit() + self.tab_nav.go_to_tab('Courseware') + + # Check that the courseware navigation appears correctly + EXPECTED_SECTIONS = { + 'Test Section': ['Test Subsection'], + 'Test Section 2': ['Test Subsection 2', 'Test Subsection 3'] + } + + actual_sections = self.course_nav.sections + for section, subsections in EXPECTED_SECTIONS.iteritems(): + self.assertIn(section, actual_sections) + self.assertEqual(actual_sections[section], EXPECTED_SECTIONS[section]) + + # Navigate to a particular section + self.course_nav.go_to_section('Test Section', 'Test Subsection') + + # Check the sequence items + EXPECTED_ITEMS = ['Test Problem 1', 'Test Problem 2', 'Test HTML'] + + actual_items = self.course_nav.sequence_items + self.assertEqual(len(actual_items), len(EXPECTED_ITEMS)) + for expected in EXPECTED_ITEMS: + self.assertIn(expected, actual_items) + + +@attr('shard_1') +class HighLevelTabTestWithGaCourseScorer(HighLevelTabTestWithGaGlobalCourseCreator): + """ + Tests that verify each of the high-level tabs available within a course. + """ + + def _auto_auth(self): + # Auto-auth register for the course + self.auto_auth_with_ga_course_scorer(self.course_id) + + +class PDFTextBooksTabTestWithGaGlobalCourseCreator(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests that verify each of the textbook tabs available within a course. + """ + def _auto_auth(self): + self.auto_auth_with_ga_global_course_creator(self.course_id) + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(PDFTextBooksTabTestWithGaGlobalCourseCreator, self).setUp() + + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.tab_nav = TabNavPage(self.browser) + + # Install a course with TextBooks + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + # Add PDF textbooks to course fixture. + for i in range(1, 3): + course_fix.add_textbook("PDF Book {}".format(i), [{"title": "Chapter Of Book {}".format(i), "url": ""}]) + + course_fix.install() + + # Auto-auth register for the course + self._auto_auth() + + def test_verify_textbook_tabs(self): + """ + Test multiple pdf textbooks loads correctly in lms. + """ + self.course_info_page.visit() + + # Verify each PDF textbook tab by visiting, it will fail if correct tab is not loaded. + for i in range(1, 3): + self.tab_nav.go_to_tab("PDF Book {}".format(i)) + + +class PDFTextBooksTabTestWithGaCourseScorer(PDFTextBooksTabTestWithGaGlobalCourseCreator): + """ + Tests that verify each of the textbook tabs available within a course. + """ + def _auto_auth(self): + self.auto_auth_with_ga_course_scorer(self.course_id) + + +@attr('shard_1') +class VisibleToStaffOnlyTest(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests that content with visible_to_staff_only set to True cannot be viewed by students. + """ + def setUp(self): + super(VisibleToStaffOnlyTest, self).setUp() + + course_fix = CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'] + ) + + course_fix.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Subsection With Locked Unit').add_children( + XBlockFixtureDesc('vertical', 'Locked Unit', metadata={'visible_to_staff_only': True}).add_children( + XBlockFixtureDesc('html', 'Html Child in locked unit', data="Visible only to staff"), + ), + XBlockFixtureDesc('vertical', 'Unlocked Unit').add_children( + XBlockFixtureDesc('html', 'Html Child in unlocked unit', data="Visible only to all"), + ) + ), + XBlockFixtureDesc('sequential', 'Unlocked Subsection').add_children( + XBlockFixtureDesc('vertical', 'Test Unit').add_children( + XBlockFixtureDesc('html', 'Html Child in visible unit', data="Visible to all"), + ) + ), + XBlockFixtureDesc('sequential', 'Locked Subsection', metadata={'visible_to_staff_only': True}).add_children( + XBlockFixtureDesc('vertical', 'Test Unit').add_children( + XBlockFixtureDesc( + 'html', 'Html Child in locked subsection', data="Visible only to staff" + ) + ) + ) + ) + ).install() + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + + def test_visible_to_ga_old_course_viewer(self): + """ + Scenario: Content marked 'visible_to_staff_only' is not visible for students in the course + Given some of the course content has been marked 'visible_to_staff_only' + And I am logged on with an authorized ga_old_course_viewer account + Then I can only see content without 'visible_to_staff_only' set to True + """ + AutoAuthPage( + self.browser, + username=GA_OLD_COURSE_VIEWER_USER_INFO['username'], + password=GA_OLD_COURSE_VIEWER_USER_INFO['password'], + email=GA_OLD_COURSE_VIEWER_USER_INFO['email'], + course_id=self.course_id + ).visit() + + self.courseware_page.visit() + self.assertEqual(2, len(self.course_nav.sections['Test Section'])) + + self.course_nav.go_to_section("Test Section", "Subsection With Locked Unit") + self.assertEqual(["Html Child in unlocked unit"], self.course_nav.sequence_items) + + self.course_nav.go_to_section("Test Section", "Unlocked Subsection") + self.assertEqual(["Html Child in visible unit"], self.course_nav.sequence_items) + + def test_visible_to_ga_global_course_creator(self): + """ + Scenario: Content marked 'visible_to_staff_only' is not visible for students in the course + Given some of the course content has been marked 'visible_to_staff_only' + And I am logged on with an authorized ga_global_course_creator account + Then I can only see content without 'visible_to_staff_only' set to True + """ + """ + Scenario: All content is visible for a user marked is_staff (different from course staff) + Given some of the course content has been marked 'visible_to_staff_only' + And I am logged on with an authorized ga_global_course_creator account + Then I can see all course content + """ + AutoAuthPage( + self.browser, + username=GA_GLOBAL_COURSE_CREATOR_USER_INFO['username'], + password=GA_GLOBAL_COURSE_CREATOR_USER_INFO['password'], + email=GA_GLOBAL_COURSE_CREATOR_USER_INFO['email'], + course_id=self.course_id + ).visit() + + self.courseware_page.visit() + self.assertEqual(3, len(self.course_nav.sections['Test Section'])) + + self.course_nav.go_to_section("Test Section", "Subsection With Locked Unit") + self.assertEqual(["Html Child in locked unit", "Html Child in unlocked unit"], self.course_nav.sequence_items) + + self.course_nav.go_to_section("Test Section", "Unlocked Subsection") + self.assertEqual(["Html Child in visible unit"], self.course_nav.sequence_items) + + self.course_nav.go_to_section("Test Section", "Locked Subsection") + self.assertEqual(["Html Child in locked subsection"], self.course_nav.sequence_items) + + def test_visible_to_ga_course_scorer(self): + """ + Scenario: Content marked 'visible_to_staff_only' is not visible for students in the course + Given some of the course content has been marked 'visible_to_staff_only' + And I am logged on with an authorized ga_course_scorer account + Then I can only see content without 'visible_to_staff_only' set to True + """ + """ + Scenario: All content is visible for a user marked is_staff (different from course staff) + Given some of the course content has been marked 'visible_to_staff_only' + And I am logged on with an authorized ga_course_scorer account + Then I can see all course content + """ + self.add_course_role(self.course_id, 'Course Scorer', GA_COURSE_SCORER_USER_INFO['email']) + + # Logout and login as a ga_course_scorer + AutoAuthPage( + self.browser, + username=GA_COURSE_SCORER_USER_INFO['username'], + password=GA_COURSE_SCORER_USER_INFO['password'], + email=GA_COURSE_SCORER_USER_INFO['email'], + course_id=self.course_id + ).visit() + + self.courseware_page.visit() + self.assertEqual(3, len(self.course_nav.sections['Test Section'])) + + self.course_nav.go_to_section("Test Section", "Subsection With Locked Unit") + self.assertEqual(["Html Child in locked unit", "Html Child in unlocked unit"], self.course_nav.sequence_items) + + self.course_nav.go_to_section("Test Section", "Unlocked Subsection") + self.assertEqual(["Html Child in visible unit"], self.course_nav.sequence_items) + + self.course_nav.go_to_section("Test Section", "Locked Subsection") + self.assertEqual(["Html Child in locked subsection"], self.course_nav.sequence_items) + + +@attr('shard_1') +class TooltipTestWithGaGlobalCourseCreator(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests that tooltips are displayed + """ + def _auto_auth(self): + # Auto-auth register for the course + self.auto_auth_with_ga_global_course_creator(self.course_id) + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(TooltipTestWithGaGlobalCourseCreator, self).setUp() + + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.tab_nav = TabNavPage(self.browser) + + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + course_fix.add_children( + XBlockFixtureDesc('static_tab', 'Test Static Tab'), + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('problem', 'Test Problem 1', data=load_data_str('multiple_choice.xml')), + XBlockFixtureDesc('problem', 'Test Problem 2', data=load_data_str('formula_problem.xml')), + XBlockFixtureDesc('html', 'Test HTML'), + ) + ) + ).install() + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + # Auto-auth register for the course + self._auto_auth() + + def test_tooltip(self): + """ + Verify that tooltips are displayed when you hover over the sequence nav bar. + """ + self.course_info_page.visit() + self.tab_nav.go_to_tab('Courseware') + + self.assertTrue(self.courseware_page.tooltips_displayed()) + + +@attr('shard_1') +class TooltipTestWithGaCourseScorer(TooltipTestWithGaGlobalCourseCreator): + """ + Tests that tooltips are displayed + """ + def _auto_auth(self): + # Auto-auth register for the course + self.auto_auth_with_ga_course_scorer(self.course_id) + + +@attr('shard_1') +class PreRequisiteCourseTest(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests that pre-requisite course messages are displayed + """ + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(PreRequisiteCourseTest, self).setUp() + + CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ).install() + + self.prc_info = { + 'org': 'test_org', + 'number': self.unique_id, + 'run': 'prc_test_run', + 'display_name': 'PR Test Course' + self.unique_id + } + + CourseFixture( + self.prc_info['org'], self.prc_info['number'], + self.prc_info['run'], self.prc_info['display_name'] + ).install() + + pre_requisite_course_key = generate_course_key( + self.prc_info['org'], + self.prc_info['number'], + self.prc_info['run'] + ) + self.pre_requisite_course_id = unicode(pre_requisite_course_key) + + self.dashboard_page = DashboardPage(self.browser) + self.settings_page = SettingsPage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + + ) + # Auto-auth register for the course + AutoAuthPage(self.browser, course_id=self.course_id).visit() + + def test_dashboard_message(self): + """ + Scenario: Any course where there is a Pre-Requisite course Student dashboard should have + appropriate messaging. + Given that I am on the Student dashboard + When I view a course with a pre-requisite course set + Then At the bottom of course I should see course requirements message.' + """ + + # visit dashboard page and make sure there is not pre-requisite course message + self.dashboard_page.visit() + self.assertFalse(self.dashboard_page.pre_requisite_message_displayed()) + + # Logout and Login as a ga_old_course_viewer + # self.switch_to_user(GA_OLD_COURSE_VIEWER_USER_INFO, self.course_id) + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_OLD_COURSE_VIEWER_USER_INFO['username'], + password=GA_OLD_COURSE_VIEWER_USER_INFO['password'], + email=GA_OLD_COURSE_VIEWER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit dashboard page and make sure there is not pre-requisite course message + self.dashboard_page.visit() + self.assertFalse(self.dashboard_page.pre_requisite_message_displayed()) + + # Logout and Login as a ga_global_course_creator + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_GLOBAL_COURSE_CREATOR_USER_INFO['username'], + password=GA_GLOBAL_COURSE_CREATOR_USER_INFO['password'], + email=GA_GLOBAL_COURSE_CREATOR_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit dashboard page and make sure there is pre-requisite course message + self.dashboard_page.visit() + self.assertFalse(self.dashboard_page.pre_requisite_message_displayed()) + + # Logout and Login as a ga_course_scorer + LogoutPage(self.browser).visit() + self.add_course_role(self.course_id, 'Course Scorer', GA_COURSE_SCORER_USER_INFO['email']) + AutoAuthPage( + self.browser, + username=GA_COURSE_SCORER_USER_INFO['username'], + password=GA_COURSE_SCORER_USER_INFO['password'], + email=GA_COURSE_SCORER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit dashboard page and make sure there is pre-requisite course message + self.dashboard_page.visit() + self.assertFalse(self.dashboard_page.pre_requisite_message_displayed()) + + # Logout and login as a staff. + LogoutPage(self.browser).visit() + AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit() + + # visit course settings page and set pre-requisite course + self.settings_page.visit() + self._set_pre_requisite_course() + + # Logout and login as a student. + LogoutPage(self.browser).visit() + AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit() + + # visit dashboard page again now it should have pre-requisite course message + self.dashboard_page.visit() + EmptyPromise(lambda: self.dashboard_page.available_courses > 0, 'Dashboard page loaded').fulfill() + self.assertTrue(self.dashboard_page.pre_requisite_message_displayed()) + + # Logout and Login as a ga_old_course_viewer + # self.switch_to_user(GA_OLD_COURSE_VIEWER_USER_INFO) + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_OLD_COURSE_VIEWER_USER_INFO['username'], + password=GA_OLD_COURSE_VIEWER_USER_INFO['password'], + email=GA_OLD_COURSE_VIEWER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit dashboard page again now it should have pre-requisite course message + self.dashboard_page.visit() + EmptyPromise(lambda: self.dashboard_page.available_courses > 0, 'Dashboard page loaded').fulfill() + self.assertTrue(self.dashboard_page.pre_requisite_message_displayed()) + + # Logout and Login as a ga_global_course_creator + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_GLOBAL_COURSE_CREATOR_USER_INFO['username'], + password=GA_GLOBAL_COURSE_CREATOR_USER_INFO['password'], + email=GA_GLOBAL_COURSE_CREATOR_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit dashboard page and make sure there is pre-requisite course message + self.dashboard_page.visit() + EmptyPromise(lambda: self.dashboard_page.available_courses > 0, 'Dashboard page loaded').fulfill() + self.assertTrue(self.dashboard_page.pre_requisite_message_displayed()) + + # Logout and Login as a ga_course_scorer + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_COURSE_SCORER_USER_INFO['username'], + password=GA_COURSE_SCORER_USER_INFO['password'], + email=GA_COURSE_SCORER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit dashboard page and make sure there is pre-requisite course message + self.dashboard_page.visit() + EmptyPromise(lambda: self.dashboard_page.available_courses > 0, 'Dashboard page loaded').fulfill() + self.assertTrue(self.dashboard_page.pre_requisite_message_displayed()) + + def _set_pre_requisite_course(self): + """ + set pre-requisite course + """ + select_option_by_value(self.settings_page.pre_requisite_course_options, self.pre_requisite_course_id) + self.settings_page.save_changes() + + +@attr('shard_1') +class ProblemExecutionTestWithGaGlobalCourseCreator(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests of problems. + """ + + def _auto_auth(self): + # Auto-auth register for the course + self.auto_auth_with_ga_global_course_creator(self.course_id) + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(ProblemExecutionTestWithGaGlobalCourseCreator, self).setUp() + + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + self.tab_nav = TabNavPage(self.browser) + + # Install a course with sections and problems. + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + course_fix.add_asset(['python_lib.zip']) + + course_fix.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('problem', 'Python Problem', data=dedent( + """\ + + + +

      What is the sum of $oneseven and 3?

      + + + + +
      + """ + )) + ) + ) + ).install() + + self._auto_auth() + + def test_python_execution_in_problem(self): + # Navigate to the problem page + self.course_info_page.visit() + self.tab_nav.go_to_tab('Courseware') + self.course_nav.go_to_section('Test Section', 'Test Subsection') + + problem_page = ProblemPage(self.browser) + # text-transform property is uppercase in original, but none in gacco.css + self.assertEqual(problem_page.problem_name, 'Python Problem') + + # Does the page have computation results? + self.assertIn("What is the sum of 17 and 3?", problem_page.problem_text) + + # Fill in the answer correctly. + problem_page.fill_answer("20") + problem_page.click_check() + self.assertTrue(problem_page.is_correct()) + + # Fill in the answer incorrectly. + problem_page.fill_answer("4") + problem_page.click_check() + self.assertFalse(problem_page.is_correct()) + + +@attr('shard_1') +class ProblemExecutionTestWithGaCourseScorer(ProblemExecutionTestWithGaGlobalCourseCreator): + """ + Tests of problems. + """ + + def _auto_auth(self): + # Auto-auth register for the course + self.auto_auth_with_ga_course_scorer(self.course_id) + + +@attr('shard_1') +class EntranceExamTest(UniqueCourseTest, GaccoTestRoleMixin): + """ + Tests that course has an entrance exam. + """ + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(EntranceExamTest, self).setUp() + + CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ).install() + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + self.settings_page = SettingsPage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + # Auto-auth register for the course + AutoAuthPage(self.browser, course_id=self.course_id).visit() + + def test_entrance_exam_section(self): + """ + Scenario: Any course that is enabled for an entrance exam, should have entrance exam chapter at courseware + page. + Given that I am on the courseware page + When I view the courseware that has an entrance exam + Then there should be an "Entrance Exam" chapter.' + """ + entrance_exam_link_selector = '.accordion .course-navigation .chapter .group-heading' + # visit courseware page and make sure there is not entrance exam chapter. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertFalse(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) + + # Logout and login as a ga_old_course_viewer + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_OLD_COURSE_VIEWER_USER_INFO['username'], + password=GA_OLD_COURSE_VIEWER_USER_INFO['password'], + email=GA_OLD_COURSE_VIEWER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # self.switch_to_user(GA_OLD_COURSE_VIEWER_USER_INFO, self.course_id) + # visit courseware page and make sure there is not entrance exam chapter. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertFalse(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) + + # Logout and login as a ga_global_course_creator + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_GLOBAL_COURSE_CREATOR_USER_INFO['username'], + password=GA_GLOBAL_COURSE_CREATOR_USER_INFO['password'], + email=GA_GLOBAL_COURSE_CREATOR_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit courseware page and make sure there is entrance exam chapter. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertFalse(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) + + # Logout and login as a ga_course_scorer + LogoutPage(self.browser).visit() + self.add_course_role(self.course_id, 'Course Scorer', GA_COURSE_SCORER_USER_INFO['email']) + AutoAuthPage( + self.browser, + username=GA_COURSE_SCORER_USER_INFO['username'], + password=GA_COURSE_SCORER_USER_INFO['password'], + email=GA_COURSE_SCORER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit courseware page and make sure there is entrance exam chapter. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertFalse(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) + + # Logout and login as a staff. + LogoutPage(self.browser).visit() + AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit() + + # visit course settings page and set/enabled entrance exam for that course. + self.settings_page.visit() + self.settings_page.wait_for_page() + self.assertTrue(self.settings_page.is_browser_on_page()) + self.settings_page.entrance_exam_field.click() + self.settings_page.save_changes() + + # Logout and login as a student. + LogoutPage(self.browser).visit() + AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit() + + # visit course info page and make sure there is an "Entrance Exam" section. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertTrue(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) + + # Logout and login as a ga_old_course_viewer + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_OLD_COURSE_VIEWER_USER_INFO['username'], + password=GA_OLD_COURSE_VIEWER_USER_INFO['password'], + email=GA_OLD_COURSE_VIEWER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # self.switch_to_user(GA_OLD_COURSE_VIEWER_USER_INFO, self.course_id) + # visit courseware page and make sure there is not entrance exam chapter. + + # visit course info page and make sure there is an "Entrance Exam" section. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertTrue(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) + + # Logout and login as a ga_global_course_creator + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_GLOBAL_COURSE_CREATOR_USER_INFO['username'], + password=GA_GLOBAL_COURSE_CREATOR_USER_INFO['password'], + email=GA_GLOBAL_COURSE_CREATOR_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit courseware page and make sure there is entrance exam chapter. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertTrue(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) + + # Logout and login as a ga_course_scorer + LogoutPage(self.browser).visit() + AutoAuthPage( + self.browser, + username=GA_COURSE_SCORER_USER_INFO['username'], + password=GA_COURSE_SCORER_USER_INFO['password'], + email=GA_COURSE_SCORER_USER_INFO['email'], + course_id=self.course_id + ).visit() + # visit courseware page and make sure there is entrance exam chapter. + self.courseware_page.visit() + self.courseware_page.wait_for_page() + self.assertTrue(element_has_text( + page=self.courseware_page, + css_selector=entrance_exam_link_selector, + text='Entrance Exam' + )) diff --git a/common/test/acceptance/tests/lms/test_ga_resign.py b/common/test/acceptance/tests/lms/test_ga_resign.py index 0dfed09c9203..c14142e0fd86 100644 --- a/common/test/acceptance/tests/lms/test_ga_resign.py +++ b/common/test/acceptance/tests/lms/test_ga_resign.py @@ -72,11 +72,11 @@ def test_resign_success(self): resign_complete_page = ResignCompletePage(self.browser).wait_for_page() bok_choy.browser.save_screenshot(self.browser, 'test_resign_success__2') - # Fail to login and redirect to disabled account page + # Fail to login login_page = CombinedLoginAndRegisterPage(self.browser, start_page='login') login_page.visit() login_page.login(self.email, self.password) - DisabledAccountPage(self.browser).wait_for_page() + login_page.wait_for_errors() bok_choy.browser.save_screenshot(self.browser, 'test_resign_success__3') def test_resign_with_empty_reason(self): diff --git a/common/test/acceptance/tests/lms/test_ga_terminated.py b/common/test/acceptance/tests/lms/test_ga_terminated.py index dbc8b4bceba4..3409c7bfb622 100644 --- a/common/test/acceptance/tests/lms/test_ga_terminated.py +++ b/common/test/acceptance/tests/lms/test_ga_terminated.py @@ -5,7 +5,7 @@ from bok_choy.web_app_test import WebAppTest from ..helpers import UniqueCourseTest -from ..ga_helpers import GaccoTestMixin, GA_OLD_COURSE_VIEWER_USER_INFO +from ..ga_helpers import GaccoTestMixin, GA_GLOBAL_COURSE_CREATOR_USER_INFO, GA_OLD_COURSE_VIEWER_USER_INFO from ...pages.lms.auto_auth import AutoAuthPage from ...pages.common.logout import LogoutPage from ...pages.lms.ga_dashboard import DashboardPage @@ -69,6 +69,13 @@ def test_dashboard_message_and_link(self): self.assertEqual(self.dashboard_page.hidden_course_text, "") self.assertTrue(self.dashboard_page.is_hidden_course_link_active(self.course_id)) + # Logout and login as a ga_global_course_creator. + self.switch_to_user(GA_GLOBAL_COURSE_CREATOR_USER_INFO, self.course_id) + + self.dashboard_page.visit() + self.assertEqual(self.dashboard_page.hidden_course_text, "") + self.assertTrue(self.dashboard_page.is_hidden_course_link_active(self.course_id)) + def test_dashboard_unenroll_when_course_has_terminated(self): """ Scenario: @@ -140,3 +147,10 @@ def test_dashboard_message_and_link(self): self.dashboard_page.visit() self.assertEqual(self.dashboard_page.hidden_course_text, "") self.assertTrue(self.dashboard_page.is_hidden_course_link_active(self.course_id)) + + # Logout and login as a ga_global_course_creator. + self.switch_to_user(GA_GLOBAL_COURSE_CREATOR_USER_INFO, self.course_id) + + self.dashboard_page.visit() + self.assertEqual(self.dashboard_page.hidden_course_text, "") + self.assertTrue(self.dashboard_page.is_hidden_course_link_active(self.course_id)) diff --git a/common/test/acceptance/tests/lms/test_ga_user_preview.py b/common/test/acceptance/tests/lms/test_ga_user_preview.py new file mode 100644 index 000000000000..8baa263ce3e5 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_ga_user_preview.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +""" +Tests the "preview" selector in the LMS that allows changing between Staff, Student, and Content Groups. +""" +from nose.plugins.attrib import attr + +from ..ga_role_helpers import GaccoTestRoleMixin +from ..helpers import UniqueCourseTest, create_user_partition_json +from ...pages.studio.auto_auth import AutoAuthPage +from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.staff_view import StaffPage +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from xmodule.partitions.partitions import Group +from textwrap import dedent + + +@attr('shard_3') +class StaffViewTest(UniqueCourseTest): + """ + Tests that verify the staff view. + """ + USERNAME = "STAFF_TESTER" + EMAIL = "johndoe@example.com" + + def _auto_auth(self): + AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, + course_id=self.course_id, staff=True).visit() + + def setUp(self): + super(StaffViewTest, self).setUp() + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + + # Install a course with sections/problems, tabs, updates, and handouts + self.course_fixture = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + self.populate_course_fixture(self.course_fixture) # pylint: disable=no-member + + self.course_fixture.install() + + # Auto-auth register for the course. + # Do this as global staff so that you will see the Staff View + self._auto_auth() + + def _goto_staff_page(self): + """ + Open staff page with assertion + """ + self.courseware_page.visit() + staff_page = StaffPage(self.browser, self.course_id) + self.assertEqual(staff_page.staff_view_mode, 'Staff') + return staff_page + + +@attr('shard_3') +class CourseWithoutContentGroupsTest(StaffViewTest): + """ + Setup for tests that have no content restricted to specific content groups. + """ + + def populate_course_fixture(self, course_fixture): + """ + Populates test course with chapter, sequential, and 2 problems. + """ + problem_data = dedent(""" + +

      Choose Yes.

      + + + Yes + + +
      + """) + + course_fixture.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('problem', 'Test Problem 1', data=problem_data), + XBlockFixtureDesc('problem', 'Test Problem 2', data=problem_data) + ) + ) + ) + + +@attr('shard_3') +class StaffViewToggleTest(CourseWithoutContentGroupsTest, GaccoTestRoleMixin): + """ + Tests for the staff view toggle button. + """ + def test_instructor_tab_visibility_with_ga_global_course_creator(self): + """ + Test that the instructor tab is always hidden by GaGlobalCourseCreator. + """ + self.logout() + self.auto_auth_with_ga_global_course_creator(self.course_id) + self.courseware_page.visit() + self.assertFalse(self.courseware_page.has_tab('Instructor')) + + def test_instructor_tab_visibility_with_ga_course_scorer(self): + """ + Test that the instructor tab is hidden when viewing as a student. + """ + self.logout() + self.auto_auth_with_ga_course_scorer(self.course_id) + course_page = self._goto_staff_page() + self.assertTrue(course_page.has_tab('Instructor')) + course_page.set_staff_view_mode('Student') + self.assertEqual(course_page.staff_view_mode, 'Student') + self.assertFalse(course_page.has_tab('Instructor')) + + +@attr('shard_3') +class StaffDebugTestWithGaCourseScorer(CourseWithoutContentGroupsTest, GaccoTestRoleMixin): + """ + Tests that verify the staff debug info. + """ + def _auto_auth(self): + self.user_info = self.auto_auth_with_ga_course_scorer(self.course_id) + + def test_enabled_staff_debug(self): + """ + Test that ga_course_scorer can view staff debug info + """ + staff_page = self._goto_staff_page() + + # 'Staff Debug Info' is capitalized. + # 'text-transform: uppercase' is set for .instructor-info-action + # in lms/static/sass/course/courseware/_courseware.scss + self.assertTrue(u'STAFF DEBUG INFO' in staff_page.q(css='a.instructor-info-action').text) + + def test_reset_attempts_empty(self): + """ + Test that we reset even when there is no student state + """ + + staff_debug_page = self._goto_staff_page().open_staff_debug_info() + staff_debug_page.reset_attempts() + msg = staff_debug_page.idash_msg[0] + self.assertEqual(u'Successfully reset the attempts ' + 'for user {}'.format(self.user_info['username']), msg) + + def test_reset_attempts_state(self): + """ + Successfully reset the student attempts + """ + staff_page = self._goto_staff_page() + staff_page.answer_problem() + + staff_debug_page = staff_page.open_staff_debug_info() + staff_debug_page.reset_attempts() + msg = staff_debug_page.idash_msg[0] + self.assertEqual(u'Successfully reset the attempts ' + 'for user {}'.format(self.user_info['username']), msg) + + def test_student_by_email(self): + """ + Successfully reset the student attempts using their email address + """ + staff_page = self._goto_staff_page() + staff_page.answer_problem() + + staff_debug_page = staff_page.open_staff_debug_info() + staff_debug_page.reset_attempts(self.user_info['email']) + msg = staff_debug_page.idash_msg[0] + self.assertEqual(u'Successfully reset the attempts ' + 'for user {}'.format(self.user_info['email']), msg) + + def test_reset_attempts_for_problem_loaded_via_ajax(self): + """ + Successfully reset the student attempts for problem loaded via ajax. + """ + staff_page = self._goto_staff_page() + staff_page.load_problem_via_ajax() + staff_page.answer_problem() + + staff_debug_page = staff_page.open_staff_debug_info() + staff_debug_page.reset_attempts() + msg = staff_debug_page.idash_msg[0] + self.assertEqual(u'Successfully reset the attempts ' + 'for user {}'.format(self.user_info['username']), msg) + + +@attr('shard_3') +class StaffDebugTestWithGaGlobalCourseCreator(CourseWithoutContentGroupsTest, GaccoTestRoleMixin): + """ + Tests that verify the staff debug info. + """ + def _auto_auth(self): + self.user_info = self.auto_auth_with_ga_global_course_creator(self.course_id) + + def test_disabled_staff_debug(self): + """ + Test that ga_global_course_creator cannot view staff debug info + """ + courseware_page = self.courseware_page.visit() + + self.assertFalse(courseware_page.q(css='a.instructor-info-action').is_present()) + + +@attr('shard_3') +class StudentHistoryViewTestWithGaCourseScorer(CourseWithoutContentGroupsTest, GaccoTestRoleMixin): + """ + Tests that verify the Student History View. + """ + def _auto_auth(self): + self.user_info = self.auto_auth_with_ga_course_scorer(self.course_id) + + def test_enabled_student_history_view(self): + """ + Test that ga_course_scorer can view Student history + """ + staff_page = self._goto_staff_page() + + # 'Submission history' is capitalized. + # 'text-transform: uppercase' is set for .instructor-info-action + # in lms/static/sass/course/courseware/_courseware.scss + self.assertTrue(u'SUBMISSION HISTORY' in staff_page.q(css='a.instructor-info-action').text) + + +@attr('shard_3') +class StudentHistoryViewTestWithGaGlobalCourseCreator(CourseWithoutContentGroupsTest, GaccoTestRoleMixin): + """ + Tests that verify the Student History View. + """ + def _auto_auth(self): + self.user_info = self.auto_auth_with_ga_global_course_creator(self.course_id) + + def test_disabled_student_history_view(self): + """ + Test that ga_global_course_creator can view Student history + """ + courseware_page = self.courseware_page.visit() + self.assertFalse(courseware_page.q(css='a.instructor-info-action').is_present()) + + +@attr('shard_3') +class CourseWithContentGroupsTest(StaffViewTest, GaccoTestRoleMixin): + """ + Verifies that changing the "View this course as" selector works properly for content groups. + """ + + def _auto_auth(self): + self.auto_auth_with_ga_global_course_creator(self.course_id) + + def setUp(self): + super(CourseWithContentGroupsTest, self).setUp() + # pylint: disable=protected-access + self.course_fixture._update_xblock(self.course_fixture._course_location, { + "metadata": { + u"user_partitions": [ + create_user_partition_json( + 0, + 'Configuration alpha,beta', + 'Content Group Partition', + [Group("0", 'alpha'), Group("1", 'beta')], + scheme="cohort" + ) + ], + }, + }) + + def populate_course_fixture(self, course_fixture): + """ + Populates test course with chapter, sequential, and 3 problems. + One problem is visible to all, one problem is visible only to Group "alpha", and + one problem is visible only to Group "beta". + """ + problem_data = dedent(""" + +

      Choose Yes.

      + + + Yes + + +
      + """) + + self.alpha_text = "VISIBLE TO ALPHA" + self.beta_text = "VISIBLE TO BETA" + self.everyone_text = "VISIBLE TO EVERYONE" + + course_fixture.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('vertical', 'Test Unit').add_children( + XBlockFixtureDesc( + 'problem', self.alpha_text, data=problem_data, metadata={"group_access": {0: [0]}} + ), + XBlockFixtureDesc( + 'problem', self.beta_text, data=problem_data, metadata={"group_access": {0: [1]}} + ), + XBlockFixtureDesc('problem', self.everyone_text, data=problem_data) + ) + ) + ) + ) + + def test_staff_sees_all_problems_with_ga_global_course_creator(self): + """ + Scenario: GaGlobalCourseCreator see all problems + Given I have a course with a cohort user partition + And problems that are associated with specific groups in the user partition + When I view the courseware in the LMS with staff access + Then I see all the problems, regardless of their group_access property + """ + self.logout() + self.auto_auth_with_ga_global_course_creator(self.course_id) + self.courseware_page.visit() + verify_expected_problem_visibility(self, self.courseware_page, [self.alpha_text, self.beta_text, self.everyone_text]) + + def test_staff_sees_all_problems_with_ga_course_scorer(self): + """ + Scenario: GaCourseScorer see all problems + Given I have a course with a cohort user partition + And problems that are associated with specific groups in the user partition + When I view the courseware in the LMS with staff access + Then I see all the problems, regardless of their group_access property + """ + self.logout() + self.auto_auth_with_ga_course_scorer(self.course_id) + self.courseware_page.visit() + verify_expected_problem_visibility(self, self.courseware_page, [self.alpha_text, self.beta_text, self.everyone_text]) + + +def verify_expected_problem_visibility(test, courseware_page, expected_problems): + """ + Helper method that checks that the expected problems are visible on the current page. + """ + test.assertEqual( + len(expected_problems), courseware_page.num_xblock_components, "Incorrect number of visible problems" + ) + for index, expected_problem in enumerate(expected_problems): + test.assertIn(expected_problem, courseware_page.xblock_components[index].text) diff --git a/common/test/acceptance/tests/studio/test_ga_studio_outline.py b/common/test/acceptance/tests/studio/test_ga_studio_outline.py new file mode 100644 index 000000000000..28dec5b065da --- /dev/null +++ b/common/test/acceptance/tests/studio/test_ga_studio_outline.py @@ -0,0 +1,133 @@ +""" +Acceptance tests for studio related to the outline page. +""" +import bok_choy.browser +import ddt + +from base_studio_test import StudioCourseTest +from ..ga_helpers import GaccoTestMixin, SUPER_USER_INFO +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from ...pages.lms.ga_django_admin import DjangoAdminPage +from ...pages.studio.ga_overview import CourseOutlinePage + + +@ddt.ddt +class SettingProgressRestrictionTest(StudioCourseTest, GaccoTestMixin): + + def setUp(self): + super(SettingProgressRestrictionTest, self).setUp() + + self.course_info = { + 'org': 'test_org_00003', + 'number': self._testMethodName, + 'run': 'test_run_00003', + 'display_name': 'Progress Restriction Course' + } + + self.course_fixture = CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'] + ) + self.course_fixture.add_children( + XBlockFixtureDesc('chapter', '1').add_children( + XBlockFixtureDesc('sequential', '1.1').add_children( + XBlockFixtureDesc('vertical', '1.1.1'), + XBlockFixtureDesc('vertical', '1.1.2') + ), + XBlockFixtureDesc('sequential', '1.2').add_children( + XBlockFixtureDesc('vertical', '1.2.1'), + XBlockFixtureDesc('vertical', '1.2.2') + ) + ), + XBlockFixtureDesc('chapter', '2').add_children( + XBlockFixtureDesc('sequential', '2.1').add_children( + XBlockFixtureDesc('vertical', '2.1.1'), + ) + ) + ) + self.course_fixture.install() + + self.user = self.course_fixture.user + self.log_in(self.user, True) + + self.course_outline_page = CourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + def _set_progress_restriction(self): + self.switch_to_user(SUPER_USER_INFO) + + DjangoAdminPage(self.browser).visit().click_add('ga_optional', 'courseoptionalconfiguration').input({ + 'enabled': True, + 'key': 'progress-restriction-settings', + 'course_key': self.course_id, + }).save() + + @ddt.data( + (0, 0, 0), + (0, 0, 1), + (0, 1, 0), + (0, 1, 1), + (1, 0, 0), + ) + @ddt.unpack + def test_show_settings_form_for_passing_mark(self, + section_at, + subsection_at, + unit_at): + """ + test for course with setting "progress-restriction-settings" + """ + self._set_progress_restriction() + self.course_outline_page.visit() + self.course_outline_page.expand_all_subsections() + unit = self.course_outline_page.section_at(section_at).subsection_at(subsection_at).unit_at(unit_at) + modal = unit.edit() + bok_choy.browser.save_screenshot(self.browser, 'show_settings_form_for_passing_mark') + self.assertTrue('Passing Mark' in modal.find_css('.modal-section-title').text) + + @ddt.data( + (0, 0, 0), + (0, 0, 1), + (0, 1, 0), + (0, 1, 1), + (1, 0, 0), + ) + @ddt.unpack + def test_does_not_show_settings_form_for_passing_mark(self, + section_at, + subsection_at, + unit_at): + """ + test for course without setting "progress-restriction-settings" + """ + self.course_outline_page.visit() + self.course_outline_page.expand_all_subsections() + unit = self.course_outline_page.section_at(section_at).subsection_at(subsection_at).unit_at(unit_at) + modal = unit.edit() + self.assertFalse('Passing Mark' in modal.find_css('.modal-section-title').text) + + @ddt.data( + '-1', + '101', + 'abcde', + ) + def test_validate_input_for_passing_mark(self, input_value): + """ + test invalid input for passing mark" + """ + self._set_progress_restriction() + self.course_outline_page.visit() + self.course_outline_page.expand_all_subsections() + unit = self.course_outline_page.section_at(0).subsection_at(0).unit_at(0) + modal = unit.edit() + self.assertEqual(u'', modal.find_css('#progress-restriction-passing-mark-error-message').text[0]) + modal.find_css('#progress-restriction-passing-mark').fill(input_value) + modal.save() + self.assertEqual(u'Please enter an integer between 0 and 100.', + modal.find_css('#progress-restriction-passing-mark-error-message').text[0]) diff --git a/common/test/db_fixtures/ga_role_user.json b/common/test/db_fixtures/ga_role_user.json index cb20d68946d6..62143eb28d0d 100644 --- a/common/test/db_fixtures/ga_role_user.json +++ b/common/test/db_fixtures/ga_role_user.json @@ -37,5 +37,75 @@ "role": "ga_old_course_viewer", "user": 9092 } + }, + { + "pk": 9093, + "model": "auth.user", + "fields": { + "date_joined": "2017-01-01 00:00:00", + "username": "Ga_Global_Course_Creator", + "first_name": "Ga_Global_Course_Creator", + "last_name": "test", + "email": "ga_global_course_creator@example.com", + "password": "Ga_Global_Course_Creator1", + "is_active": true + } + }, + { + "pk": 9093, + "model": "student.userprofile", + "fields": { + "user": 9093, + "name": "ga course editor test", + "courseware": "course.xml", + "allow_certificate": true + } + }, + { + "pk": 9093, + "model": "student.registration", + "fields": { + "user": 9093, + "activation_key": "TuC53PTAh6Kb3wEyaRi3HFQcKieB9093" + } + }, + { + "pk": 9093, + "model": "student.courseaccessrole", + "fields": { + "role": "ga_global_course_creator", + "user": 9093 + } + }, + { + "pk": 9094, + "model": "auth.user", + "fields": { + "date_joined": "2017-01-01 00:00:00", + "username": "Ga_Course_Scorer", + "first_name": "Ga_Course_Scorer", + "last_name": "test", + "email": "ga_course_scorer@example.com", + "password": "Ga_Course_Scorer1", + "is_active": true + } + }, + { + "pk": 9094, + "model": "student.userprofile", + "fields": { + "user": 9094, + "name": "ga lms course staff test", + "courseware": "course.xml", + "allow_certificate": true + } + }, + { + "pk": 9094, + "model": "student.registration", + "fields": { + "user": 9094, + "activation_key": "TuC53PTAh6Kb3wEyaRi3HFQcKieB9094" + } } ] diff --git a/conf/locale/ja_JP/LC_MESSAGES/django.po b/conf/locale/ja_JP/LC_MESSAGES/django.po index f92e7d0d0a80..37806c80cb17 100644 --- a/conf/locale/ja_JP/LC_MESSAGES/django.po +++ b/conf/locale/ja_JP/LC_MESSAGES/django.po @@ -9791,7 +9791,7 @@ msgstr "プロファイル画像を削除" #: lms/templates/manage_user_standing.html msgid "Students whose accounts have been disabled" -msgstr "失効となっているアカウント" +msgstr "失効となっているアカウントです" #: lms/templates/manage_user_standing.html msgid "(reload your page to refresh)" diff --git a/conf/locale/ja_JP/LC_MESSAGES/mako.po b/conf/locale/ja_JP/LC_MESSAGES/mako.po index 5acbb6c4f3f7..f54bf7760cf0 100644 --- a/conf/locale/ja_JP/LC_MESSAGES/mako.po +++ b/conf/locale/ja_JP/LC_MESSAGES/mako.po @@ -1083,7 +1083,7 @@ msgstr "プロファイル画像を削除" #: lms/templates/manage_user_standing.html msgid "Students whose accounts have been disabled" -msgstr "失効となっているアカウント" +msgstr "失効となっているアカウントです" #: lms/templates/manage_user_standing.html msgid "(reload your page to refresh)" diff --git a/ga/conf/locale/ja_JP/LC_MESSAGES/biz.po b/ga/conf/locale/ja_JP/LC_MESSAGES/biz.po index 0abbe2bda526..5fdf75716e70 100644 --- a/ga/conf/locale/ja_JP/LC_MESSAGES/biz.po +++ b/ga/conf/locale/ja_JP/LC_MESSAGES/biz.po @@ -319,9 +319,12 @@ msgstr "{0}には重複した値を入力できません。" ##### Course Operation #: biz/templates/ga_course_operation/survey.html -msgid "The survey result can be downloaded as a CSV file. When you try to open a CSV file as it is in Excel, because that would garbled in the case that contains the Japanese, once you save the file, please re- open it in a text editor that corresponds to the 'UTF-8'. In addition, data on the date and time in the CSV file all UTC we (the world standard time, from Japan Standard Time minus 9 hours) are listed in the." -msgstr "アンケート結果がCSVファイルでダウンロードできます。
      " -"CSVファイルをそのままExcelで開こうとすると、日本語が含まれている場合に文字化けしてしまいますので、一旦ファイルを保存した後、「UTF-8」に対応したテキストエディタで開き直してください。また、CSVファイル内の日時に関するデータは全てUTC(世界標準時、日本標準時からマイナス9時間)で記載されています。" +msgid "" +"The survey result can be downloaded as a CSV file.
      " +"When you try to open a CSV file as it is in Excel, because that would garbled in the case that contains the Japanese, once you save the file, please re-open it in a text editor that corresponds to the 'UTF-8'." +msgstr "" +"アンケート結果がCSVファイルでダウンロードできます。
      " +"CSVファイルをそのままExcelで開こうとすると、日本語が含まれている場合に文字化けしてしまいますので、一旦ファイルを保存した後、「UTF-8」に対応したテキストエディタで開き直してください。" ##### Course Selection #: biz/djangoapps/util/decorators.py diff --git a/ga/conf/locale/ja_JP/LC_MESSAGES/gacco.po b/ga/conf/locale/ja_JP/LC_MESSAGES/gacco.po index 4830dba1b96d..f86914929573 100644 --- a/ga/conf/locale/ja_JP/LC_MESSAGES/gacco.po +++ b/ga/conf/locale/ja_JP/LC_MESSAGES/gacco.po @@ -1102,12 +1102,16 @@ msgid "Display Order" msgstr "表示順" #: lms/djangoapps/ga_advanced_course/models.py -msgid "Start Time" -msgstr "開始時間" +msgid "Start Date (JST)" +msgstr "開始日 (JST)" #: lms/djangoapps/ga_advanced_course/models.py -msgid "End Time" -msgstr "終了時間" +msgid "Start Time (JST)" +msgstr "開始時間 (JST)" + +#: lms/djangoapps/ga_advanced_course/models.py +msgid "End Time (JST)" +msgstr "終了時間 (JST)" #: lms/djangoapps/ga_advanced_course/models.py msgid "Capacity" @@ -1193,6 +1197,10 @@ msgstr "ユーザーのオプション機能設定" msgid "Hide the e-mail on the Account Settings" msgstr "アカウント設定画面のメールアドレス欄非表示" +#: openedx/core/djangoapps/ga_optional/models.py +msgid "Progress Restriction by Correct Answer Rate" +msgstr "問題正答率による進行制限" + #: cms/templates/settings.html msgid "Custom Logo for Settings" msgstr "カスタムロゴ設定" @@ -1643,6 +1651,14 @@ msgstr "%Y年%b%d日 %H:%M" msgid "Account is not valid anymore" msgstr "このアカウントは既に退会しています" +#: lms/templates/disabled_account.html +msgid "The user is resigned." +msgstr "指定されたユーザーは退会済みです。" + +#: lms/templates/disabled_account.html +msgid "Resigned User" +msgstr "退会済みユーザー" + #: lms/templates/disabled_account.html msgid "The log-in email address is already withdrawn from gacco." msgstr "ログイン時に利用されたメールアドレスは既に退会されています。" @@ -1756,3 +1772,19 @@ msgstr "" #: lms/static/coffee/src/instructor_dashboard/data_download.js msgid "Error generating playback status report. Please try again." msgstr "動画視聴状況レポート作成に失敗しました。再試行してください。" + +#: lms/templates/instructor/instructor_dashboard_2/membership.html +msgid "Course Scorer" +msgstr "講座スタッフ(採点用権限)" + +#: lms/templates/instructor/instructor_dashboard_2/membership.html +msgid "" +"Course scorer members help you manage your course. Course scorer can browse " +"to the students' reports, and view the assignment submission history. Course scorer does not " +"have access to Studio." +msgstr "" +"講座スタッフ(採点用権限)は講座の一部の管理業務を支援することができます。受講者のレポートの閲覧や採点、課題提出履歴を見ることができます。Studioへのアクセス権はありません。" + +#: lms/templates/instructor/instructor_dashboard_2/membership.html +msgid "Add Course Scorer" +msgstr "講座スタッフ(採点用権限)を追加する" diff --git a/ga/conf/locale/ja_JP/LC_MESSAGES/gaccojs.po b/ga/conf/locale/ja_JP/LC_MESSAGES/gaccojs.po index 666eb2012234..936c951c12c4 100644 --- a/ga/conf/locale/ja_JP/LC_MESSAGES/gaccojs.po +++ b/ga/conf/locale/ja_JP/LC_MESSAGES/gaccojs.po @@ -184,6 +184,10 @@ msgstr "選びなおす" msgid "Is this your final answer?" msgstr "解答を提出します。よろしいですか?" +#: common/lib/xmodule/xmodule/js/src/sequence/display.coffee +msgid "You need to make correct answers for the some problems in the before quiz." +msgstr "前の課題に合格するまでアクセスできません。" + #: lms/static/js/progress_report.js msgid "Course" msgstr "コース" @@ -283,7 +287,7 @@ msgstr "このサイトを利用するための言語です。現在、このサ #: lms/static/js/student_account/views/account_settings_fields.js msgid "An email has been sent to {email_address}. Follow the link in the email to resign." -msgstr "まだ退会手続きは完了していません。「{email_address}」宛に確認メールが送信されます。メール文中にある退会手続き用のリンクをクリックをすることで、退会処理をすることができます。なお、退会したメールアドレスの再利用や再登録はできませんのでご注意ください。" +msgstr "まだ退会手続きは完了していません。「{email_address}」宛に確認メールが送信されます。メール文中にある退会手続き用のリンクをクリックをすることで、退会処理をすることができます。" #: lms/templates/student_account/register.underscore msgid "Asterisk (*) marked fields are required." @@ -545,6 +549,14 @@ msgstr "時:" msgid "Minutes:" msgstr "分:" +#: cms/templates/js/progress-restriction-editor.underscore +msgid "Passing Mark" +msgstr "合格基準" + +#: cms/templates/js/progress-restriction-editor.underscore +msgid "Pass Mark Needed for Progress:" +msgstr "合格に必要な正答率:" + #: cms/static/js/views/modals/course_outline_modals.js msgid "Please enter the date on and after 1900." msgstr "1900年以降の日付を入力して下さい。" diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index b7d9656cd4b7..abbdbaf615a1 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -43,7 +43,7 @@ from courseware.courses import get_course from openedx.core.lib.courses import course_image_url from student.models import UserStanding -from student.roles import CourseStaffRole, CourseInstructorRole +from student.roles import CourseStaffRole, CourseInstructorRole, GaCourseScorerRole from instructor_task.models import InstructorTask from instructor_task.subtasks import ( SubtaskStatus, @@ -120,7 +120,9 @@ def _get_recipient_querysets(user_id, to_option, course_id): else: staff_qset = CourseStaffRole(course_id).users_with_role() instructor_qset = CourseInstructorRole(course_id).users_with_role() - staff_instructor_qset = (staff_qset | instructor_qset).distinct() + # Note: Include GaCourseScorer in mail sending (#2150) + ga_course_scorer_qset = GaCourseScorerRole(course_id).users_with_role() + staff_instructor_qset = (staff_qset | instructor_qset | ga_course_scorer_qset).distinct() if to_option == SEND_TO_STAFF: return [use_read_replica_if_available(staff_instructor_qset)] @@ -165,7 +167,9 @@ def _get_advanced_course_recipient_querysets(course_id, advanced_course_id): # members here staff_qset = CourseStaffRole(course_id).users_with_role() instructor_qset = CourseInstructorRole(course_id).users_with_role() - staff_instructor_qset = (staff_qset | instructor_qset).distinct() + # Note: Include GaCourseScorer in mail sending (#2150) + ga_course_scorer_qset = GaCourseScorerRole(course_id).users_with_role() + staff_instructor_qset = (staff_qset | instructor_qset | ga_course_scorer_qset).distinct() unpurchased_staff_qset = staff_instructor_qset.exclude( id__in=purchased_user_ids, diff --git a/lms/djangoapps/bulk_email/tests/test_tasks.py b/lms/djangoapps/bulk_email/tests/test_tasks.py index 5e729ee90317..84b55b1821fe 100644 --- a/lms/djangoapps/bulk_email/tests/test_tasks.py +++ b/lms/djangoapps/bulk_email/tests/test_tasks.py @@ -33,7 +33,11 @@ from xmodule.modulestore.tests.factories import CourseFactory from bulk_email.models import CourseEmail, Optout, SEND_TO_ALL, SEND_TO_ALL_INCLUDE_OPTOUT, SEND_TO_ADVANCED_COURSE - +from courseware.tests.factories import ( + GaCourseScorerFactory, + InstructorFactory, + StaffFactory, +) from instructor_task.tasks import send_bulk_course_email from instructor_task.subtasks import update_subtask_status, SubtaskStatus from instructor_task.models import InstructorTask @@ -229,6 +233,18 @@ def test_successful(self): get_conn.return_value.send_messages.side_effect = cycle([None]) self._test_run_with_task(send_bulk_course_email, 'emailed', num_emails, num_emails) + def test_successful_includes_course_staff(self): + # Select number of emails to fit into a single subtask. + num_emails = settings.BULK_EMAIL_EMAILS_PER_TASK + # Add course staff + StaffFactory(course_key=self.course.id) + GaCourseScorerFactory(course_key=self.course.id) + # We also send email to the instructor: + self._create_students(num_emails - 3) + with patch('bulk_email.tasks.get_connection', autospec=True) as get_conn: + get_conn.return_value.send_messages.side_effect = cycle([None]) + self._test_run_with_task(send_bulk_course_email, 'emailed', num_emails, num_emails) + def test_successful_twice(self): # Select number of emails to fit into a single subtask. num_emails = settings.BULK_EMAIL_EMAILS_PER_TASK diff --git a/lms/djangoapps/ccx/models.py b/lms/djangoapps/ccx/models.py index 2baf72ee886c..5167fbd2bc53 100644 --- a/lms/djangoapps/ccx/models.py +++ b/lms/djangoapps/ccx/models.py @@ -6,9 +6,10 @@ from django.contrib.auth.models import User from django.db import models -from django.utils.timezone import UTC +from pytz import utc from lazy import lazy +from openedx.core.lib.time_zone_utils import get_time_zone_abbr from xmodule_django.models import CourseKeyField, LocationKeyField from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore @@ -68,43 +69,43 @@ def max_student_enrollments_allowed(self): def has_started(self): """Return True if the CCX start date is in the past""" - return datetime.now(UTC()) > self.start + return datetime.now(utc) > self.start def has_ended(self): """Return True if the CCX due date is set and is in the past""" if self.due is None: return False - return datetime.now(UTC()) > self.due + return datetime.now(utc) > self.due - def start_datetime_text(self, format_string="SHORT_DATE"): + def start_datetime_text(self, format_string="SHORT_DATE", time_zone=utc): """Returns the desired text representation of the CCX start datetime - The returned value is always expressed in UTC + The returned value is in specified time zone, defaulted to UTC. """ i18n = self.course.runtime.service(self.course, "i18n") strftime = i18n.strftime - value = strftime(self.start, format_string) + value = strftime(self.start.astimezone(time_zone), format_string) if format_string == 'DATE_TIME': - value += u' UTC' + value += ' ' + get_time_zone_abbr(time_zone, self.start) return value - def end_datetime_text(self, format_string="SHORT_DATE"): + def end_datetime_text(self, format_string="SHORT_DATE", time_zone=utc): """Returns the desired text representation of the CCX due datetime If the due date for the CCX is not set, the value returned is the empty string. - The returned value is always expressed in UTC + The returned value is in specified time zone, defaulted to UTC. """ if self.due is None: return '' i18n = self.course.runtime.service(self.course, "i18n") strftime = i18n.strftime - value = strftime(self.due, format_string) + value = strftime(self.due.astimezone(time_zone), format_string) if format_string == 'DATE_TIME': - value += u' UTC' + value += ' ' + get_time_zone_abbr(time_zone, self.due) return value diff --git a/lms/djangoapps/ccx/tests/test_models.py b/lms/djangoapps/ccx/tests/test_models.py index 2a1c9750928d..b94ecd8303a9 100644 --- a/lms/djangoapps/ccx/tests/test_models.py +++ b/lms/djangoapps/ccx/tests/test_models.py @@ -1,10 +1,11 @@ """ tests for the models """ +import ddt from datetime import datetime, timedelta -from django.utils.timezone import UTC from mock import patch from nose.plugins.attrib import attr +from pytz import timezone, utc from student.roles import CourseCcxCoachRole from student.tests.factories import ( AdminFactory, @@ -22,6 +23,7 @@ from ..overrides import override_field_for_ccx +@ddt.ddt @attr('shard_1') class TestCCX(ModuleStoreTestCase): """Unit tests for the CustomCourseForEdX model @@ -64,7 +66,7 @@ def test_ccx_start_is_correct(self): For this reason we test the difference between and make sure it is less than one second. """ - expected = datetime.now(UTC()) + expected = datetime.now(utc) self.set_ccx_override('start', expected) actual = self.ccx.start # pylint: disable=no-member diff = expected - actual @@ -72,7 +74,7 @@ def test_ccx_start_is_correct(self): def test_ccx_start_caching(self): """verify that caching the start property works to limit queries""" - now = datetime.now(UTC()) + now = datetime.now(utc) self.set_ccx_override('start', now) with check_mongo_calls(1): # these statements are used entirely to demonstrate the @@ -89,7 +91,7 @@ def test_ccx_due_without_override(self): def test_ccx_due_is_correct(self): """verify that the due datetime for a ccx is correctly retrieved""" - expected = datetime.now(UTC()) + expected = datetime.now(utc) self.set_ccx_override('due', expected) actual = self.ccx.due # pylint: disable=no-member diff = expected - actual @@ -97,7 +99,7 @@ def test_ccx_due_is_correct(self): def test_ccx_due_caching(self): """verify that caching the due property works to limit queries""" - expected = datetime.now(UTC()) + expected = datetime.now(utc) self.set_ccx_override('due', expected) with check_mongo_calls(1): # these statements are used entirely to demonstrate the @@ -109,7 +111,7 @@ def test_ccx_due_caching(self): def test_ccx_has_started(self): """verify that a ccx marked as starting yesterday has started""" - now = datetime.now(UTC()) + now = datetime.now(utc) delta = timedelta(1) then = now - delta self.set_ccx_override('start', then) @@ -117,7 +119,7 @@ def test_ccx_has_started(self): def test_ccx_has_not_started(self): """verify that a ccx marked as starting tomorrow has not started""" - now = datetime.now(UTC()) + now = datetime.now(utc) delta = timedelta(1) then = now + delta self.set_ccx_override('start', then) @@ -125,7 +127,7 @@ def test_ccx_has_not_started(self): def test_ccx_has_ended(self): """verify that a ccx that has a due date in the past has ended""" - now = datetime.now(UTC()) + now = datetime.now(utc) delta = timedelta(1) then = now - delta self.set_ccx_override('due', then) @@ -134,7 +136,7 @@ def test_ccx_has_ended(self): def test_ccx_has_not_ended(self): """verify that a ccx that has a due date in the future has not eneded """ - now = datetime.now(UTC()) + now = datetime.now(utc) delta = timedelta(1) then = now + delta self.set_ccx_override('due', then) @@ -151,7 +153,7 @@ def test_ccx_without_due_date_has_not_ended(self): })) def test_start_datetime_short_date(self): """verify that the start date for a ccx formats properly by default""" - start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) + start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc) expected = "Jan 01, 2015" self.set_ccx_override('start', start) actual = self.ccx.start_datetime_text() # pylint: disable=no-member @@ -162,18 +164,34 @@ def test_start_datetime_short_date(self): })) def test_start_datetime_date_time_format(self): """verify that the DATE_TIME format also works as expected""" - start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) + start = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc) expected = "Jan 01, 2015 at 12:00 UTC" self.set_ccx_override('start', start) actual = self.ccx.start_datetime_text('DATE_TIME') # pylint: disable=no-member self.assertEqual(expected, actual) + @ddt.data((datetime(2015, 11, 1, 8, 59, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:59 PDT"), + (datetime(2015, 11, 1, 9, 00, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:00 PST")) + @ddt.unpack + def test_start_date_time_zone(self, start_date_time, expected_short_date, expected_date_time): + """ + verify that start date is correctly converted when time zone specified + during normal daylight hours and daylight savings hours + """ + time_zone = timezone('America/Los_Angeles') + + self.set_ccx_override('start', start_date_time) + actual_short_date = self.ccx.start_datetime_text(time_zone=time_zone) # pylint: disable=no-member + actual_datetime = self.ccx.start_datetime_text('DATE_TIME', time_zone) # pylint: disable=no-member + self.assertEqual(expected_short_date, actual_short_date) + self.assertEqual(expected_date_time, actual_datetime) + @patch('util.date_utils.ugettext', fake_ugettext(translations={ "SHORT_DATE_FORMAT": "%b %d, %Y", })) def test_end_datetime_short_date(self): """verify that the end date for a ccx formats properly by default""" - end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) + end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc) expected = "Jan 01, 2015" self.set_ccx_override('due', end) actual = self.ccx.end_datetime_text() # pylint: disable=no-member @@ -184,12 +202,28 @@ def test_end_datetime_short_date(self): })) def test_end_datetime_date_time_format(self): """verify that the DATE_TIME format also works as expected""" - end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=UTC()) + end = datetime(2015, 1, 1, 12, 0, 0, tzinfo=utc) expected = "Jan 01, 2015 at 12:00 UTC" self.set_ccx_override('due', end) actual = self.ccx.end_datetime_text('DATE_TIME') # pylint: disable=no-member self.assertEqual(expected, actual) + @ddt.data((datetime(2015, 11, 1, 8, 59, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:59 PDT"), + (datetime(2015, 11, 1, 9, 00, 00, tzinfo=utc), "Nov 01, 2015", "Nov 01, 2015 at 01:00 PST")) + @ddt.unpack + def test_end_datetime_time_zone(self, end_date_time, expected_short_date, expected_date_time): + """ + verify that end date is correctly converted when time zone specified + during normal daylight hours and daylight savings hours + """ + time_zone = timezone('America/Los_Angeles') + + self.set_ccx_override('due', end_date_time) + actual_short_date = self.ccx.end_datetime_text(time_zone=time_zone) # pylint: disable=no-member + actual_datetime = self.ccx.end_datetime_text('DATE_TIME', time_zone) # pylint: disable=no-member + self.assertEqual(expected_short_date, actual_short_date) + self.assertEqual(expected_date_time, actual_datetime) + @patch('util.date_utils.ugettext', fake_ugettext(translations={ "DATE_TIME_FORMAT": "%b %d, %Y at %H:%M", })) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 2b8af9fbc0ab..1ff27d89bfab 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -42,6 +42,8 @@ CourseInstructorRole, CourseStaffRole, GaAnalyzerRole, + GaCourseScorerRole, + GaGlobalCourseCreatorRole, GaOldCourseViewerStaffRole, GlobalStaff, SupportStaffRole, @@ -70,6 +72,7 @@ log = logging.getLogger(__name__) GA_ACCESS_CHECK_TYPE_ANALYZER = 'ga_analyzer' GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW = 'ga_old_course_view' +GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR = 'ga_global_course_creator' def has_access(user, action, obj, course_key=None): @@ -311,6 +314,9 @@ def _can_enroll_courselike(user, courselike): is_old_course_viewer = _has_access_string(user, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW, 'global') if CourseEnrollmentAllowed.objects.filter(email=user.email, course_id=course_key): return ACCESS_GRANTED + # Note: GaGlobalCourseCreator can enroll courses regardless of the course start or terminate (#2150) + if _has_access_string(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global'): + return ACCESS_GRANTED if _has_staff_access_to_descriptor(user, courselike, course_key): return ACCESS_GRANTED @@ -365,7 +371,8 @@ def can_load(): ) return ( - ACCESS_GRANTED if (response or _has_staff_access_to_descriptor(user, courselike, courselike.id)) + ACCESS_GRANTED if (response or _has_staff_access_to_descriptor(user, courselike, courselike.id) + or _has_access_string(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global')) else response ) @@ -391,6 +398,7 @@ def can_see_in_catalog(): return ( _has_catalog_visibility(courselike, CATALOG_VISIBILITY_CATALOG_AND_ABOUT) or _has_staff_access_to_descriptor(user, courselike, courselike.id) + or _has_access_string(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global') ) def can_see_about_page(): @@ -403,6 +411,7 @@ def can_see_about_page(): _has_catalog_visibility(courselike, CATALOG_VISIBILITY_CATALOG_AND_ABOUT) or _has_catalog_visibility(courselike, CATALOG_VISIBILITY_ABOUT) or _has_staff_access_to_descriptor(user, courselike, courselike.id) + or _has_access_string(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global') ) checkers = { @@ -544,7 +553,8 @@ def can_load(): ) return ( - ACCESS_GRANTED if (response or _has_staff_access_to_descriptor(user, descriptor, course_key)) + ACCESS_GRANTED if (response or _has_staff_access_to_descriptor(user, descriptor, course_key) + or _has_access_string(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global')) else response ) @@ -663,12 +673,22 @@ def check_old_course_view(): else ACCESS_DENIED ) + def check_ga_global_course_creator(): + """Checks for ga_global_course_creator access. """ + if perm != 'global': + return ACCESS_DENIED + return ( + ACCESS_GRANTED if GaGlobalCourseCreatorRole().has_user(user) + else ACCESS_DENIED + ) + checkers = { 'staff': check_staff, 'support': check_support, 'certificates': check_support, GA_ACCESS_CHECK_TYPE_ANALYZER: check_ga_analyzer, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW: check_old_course_view, + GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR: check_ga_global_course_creator, } return _dispatch(checkers, action, user, perm) @@ -750,14 +770,20 @@ def _has_access_to_course(user, access_level, course_key): debug("Allow: user.is_staff") return ACCESS_GRANTED + # Note: GaGlobalCourseCreator is not a staff at LMS (#2150) + if _has_access_string(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global'): + return ACCESS_DENIED + if access_level not in ('staff', 'instructor'): log.debug("Error in access._has_access_to_course access_level=%s unknown", access_level) debug("Deny: unknown access level") return ACCESS_DENIED + # Note: GaCourseScorer is a staff at LMS (#2150) staff_access = ( CourseStaffRole(course_key).has_user(user) or - OrgStaffRole(course_key.org).has_user(user) + OrgStaffRole(course_key.org).has_user(user) or + GaCourseScorerRole(course_key).has_user(user) ) if staff_access and access_level == 'staff': debug("Allow: user has course staff access") diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index f3265a72e786..fbecbcbd7ccb 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -8,13 +8,15 @@ from babel.dates import format_timedelta from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy from django.utils.translation import to_locale, get_language from edxmako.shortcuts import render_to_string from lazy import lazy -import pytz +from pytz import utc from course_modes.models import CourseMode from lms.djangoapps.verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification +from openedx.core.lib.time_zone_utils import get_time_zone_abbr, get_user_time_zone from student.models import CourseEnrollment @@ -50,7 +52,7 @@ def date_format(self): The format to display this date in. By default, displays like Jan 01, 2015. """ - return '%b %d, %Y' + return u'%b %d, %Y' @property def link(self): @@ -62,34 +64,20 @@ def link_text(self): """The text of the link.""" return '' + @property + def time_zone(self): + """The time zone to display in""" + return get_user_time_zone(self.user) + def __init__(self, course, user): self.course = course self.user = user def get_context(self): """Return the template context used to render this summary block.""" - date = '' - if self.date is not None: - # Translators: relative_date is a fuzzy description of the - # time from now until absolute_date. For example, - # absolute_date might be "Jan 01, 2020", and if today were - # December 5th, 2020, relative_date would be "1 month". - locale = to_locale(get_language()) - try: - relative_date = format_timedelta(self.date - datetime.now(pytz.UTC), locale=locale) - # Babel doesn't have translations for Esperanto, so we get - # a KeyError when testing translations with - # ?preview-lang=eo. This should not happen with any other - # languages. See https://github.com/python-babel/babel/issues/107 - except KeyError: - relative_date = format_timedelta(self.date - datetime.now(pytz.UTC)) - date = _("in {relative_date} - {absolute_date}").format( - relative_date=relative_date, - absolute_date=self.date.strftime(self.date_format), - ) return { 'title': self.title, - 'date': date, + 'date': self._format_date(), 'description': self.description, 'css_class': self.css_class, 'link': self.link, @@ -102,6 +90,35 @@ def render(self): """ return render_to_string('courseware/date_summary.html', self.get_context()) + def _format_date(self): + """ + Return this block's date in a human-readable format. If the date + is None, returns the empty string. + """ + if self.date is None: + return '' + locale = to_locale(get_language()) + delta = self.date - datetime.now(utc) + try: + relative_date = format_timedelta(delta, locale=locale) + # Babel doesn't have translations for Esperanto, so we get + # a KeyError when testing translations with + # ?preview-lang=eo. This should not happen with any other + # languages. See https://github.com/python-babel/babel/issues/107 + except KeyError: + relative_date = format_timedelta(delta) + date_has_passed = delta.days < 0 + # Translators: 'absolute' is a date such as "Jan 01, + # 2020". 'relative' is a fuzzy description of the time until + # 'absolute'. For example, 'absolute' might be "Jan 01, 2020", + # and if today were December 5th, 2020, 'relative' would be "1 + # month". + date_format = _(u"{relative} ago - {absolute}") if date_has_passed else _(u"in {relative} - {absolute}") + return date_format.format( + relative=relative_date, + absolute=self.date.astimezone(self.time_zone).strftime(self.date_format.encode('utf-8')).decode('utf-8'), + ) + @property def is_enabled(self): """ @@ -111,11 +128,11 @@ def is_enabled(self): future. """ if self.date is not None: - return datetime.now(pytz.UTC) <= self.date + return datetime.now(utc) <= self.date return False def __repr__(self): - return 'DateSummary: "{title}" {date} is_enabled={is_enabled}'.format( + return u'DateSummary: "{title}" {date} is_enabled={is_enabled}'.format( title=self.title, date=self.date, is_enabled=self.is_enabled @@ -128,7 +145,10 @@ class TodaysDate(DateSummary): """ css_class = 'todays-date' is_enabled = True - date_format = u'%b %d, %Y (%H:%M {utc})'.format(utc=_('UTC')) + + @property + def date_format(self): + return u'%b %d, %Y (%H:%M {tz_abbr})'.format(tz_abbr=get_time_zone_abbr(self.time_zone)) # The date is shown in the title, no need to display it again. def get_context(self): @@ -138,11 +158,13 @@ def get_context(self): @property def date(self): - return datetime.now(pytz.UTC) + return datetime.now(utc) @property def title(self): - return _('Today is {date}').format(date=datetime.now(pytz.UTC).strftime(self.date_format)) + return _(u'Today is {date}').format( + date=self.date.astimezone(self.time_zone).strftime(self.date_format.encode('utf-8')).decode('utf-8') + ) class CourseStartDate(DateSummary): @@ -150,7 +172,7 @@ class CourseStartDate(DateSummary): Displays the start date of the course. """ css_class = 'start-date' - title = _('Course Starts') + title = ugettext_lazy('Course Starts') @property def date(self): @@ -162,7 +184,7 @@ class CourseEndDate(DateSummary): Displays the end date of the course. """ css_class = 'end-date' - title = _('Course End') + title = ugettext_lazy('Course End') @property def is_enabled(self): @@ -170,8 +192,12 @@ def is_enabled(self): @property def description(self): - if datetime.now(pytz.UTC) <= self.date: - return _('To earn a certificate, you must complete all requirements before this date.') + if datetime.now(utc) <= self.date: + mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + if is_active and CourseMode.is_eligible_for_certificate(mode): + return _('To earn a certificate, you must complete all requirements before this date.') + else: + return _('After this date, course content will be archived.') return _('This course is archived, which means you can review course content but it is no longer active.') @property @@ -185,9 +211,12 @@ class VerifiedUpgradeDeadlineDate(DateSummary): Verified track. """ css_class = 'verified-upgrade-deadline' - title = _('Verification Upgrade Deadline') - description = _('You are still eligible to upgrade to a Verified Certificate!') - link_text = _('Upgrade to Verified Certificate') + title = ugettext_lazy('Verification Upgrade Deadline') + description = ugettext_lazy( + 'You are still eligible to upgrade to a Verified Certificate! ' + 'Pursue it to highlight the knowledge and skills you gain in this course.' + ) + link_text = ugettext_lazy('Upgrade to Verified Certificate') @property def link(self): @@ -281,7 +310,7 @@ def deadline_has_passed(self): Return True if a verification deadline exists, and has already passed. """ deadline = self.date - return deadline is not None and deadline <= datetime.now(pytz.UTC) + return deadline is not None and deadline <= datetime.now(utc) def must_retry(self): """Return True if the user must re-submit verification, False otherwise.""" diff --git a/lms/djangoapps/courseware/ga_access.py b/lms/djangoapps/courseware/ga_access.py index a6e9e92447d2..d13a86efb671 100644 --- a/lms/djangoapps/courseware/ga_access.py +++ b/lms/djangoapps/courseware/ga_access.py @@ -1,6 +1,6 @@ from django.conf import settings -from courseware.access import has_access, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW +from courseware.access import has_access, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from student.models import CourseEnrollment from student.roles import CourseBetaTesterRole @@ -20,16 +20,18 @@ def is_terminated(courselike, user): is_global_staff = has_access(user, 'staff', 'global') is_old_course_viewer = has_access(user, GA_ACCESS_CHECK_TYPE_OLD_COURSE_VIEW, 'global') + is_ga_global_course_creator = has_access(user, GA_ACCESS_CHECK_TYPE_GLOBAL_COURSE_CREATOR, 'global') is_course_staff = has_access(user, 'staff', courselike) is_course_beta_tester = CourseBetaTesterRole(courselike.id).has_user(user) - # Note1: Even if course terminate date has passed, GlobalStaff and OldCourseViewer can access it. (#2197) + # Note1: Even if course terminate date has passed, GlobalStaff, OldCourseViewer and GaGlobalCourseCreator can access it. (#2197, #2150) # Note2: Even if self-paced course and its individual end date has passed, - # GlobalStaff, OldCourseViewer, CourseAdmin(Instructor), CourseStaff and BetaTester can access it. (#2197) + # GlobalStaff, OldCourseViewer, GaGlobalCourseCreator, CourseAdmin(Instructor), CourseStaff and BetaTester can access it. (#2197, #2150) return ( - not (is_global_staff or is_old_course_viewer) and _is_terminated(courselike) + not (is_global_staff or is_old_course_viewer or is_ga_global_course_creator) and _is_terminated(courselike) ) or ( - not (is_global_staff or is_old_course_viewer or is_course_staff or is_course_beta_tester) and _is_individual_closed(courselike, user) + not (is_global_staff or is_old_course_viewer or is_ga_global_course_creator or is_course_staff or is_course_beta_tester) + and _is_individual_closed(courselike, user) ) diff --git a/lms/djangoapps/courseware/ga_progress_restriction.py b/lms/djangoapps/courseware/ga_progress_restriction.py new file mode 100644 index 000000000000..b84efab872e0 --- /dev/null +++ b/lms/djangoapps/courseware/ga_progress_restriction.py @@ -0,0 +1,138 @@ +""" +Progress Restriction +""" +import json +from courseware.models import StudentModuleHistory + +PROGRESS_RESTRICTION_TYPE = { + 'no-restriction': 'No Restriction', + 'correct-answer-rate': 'Correct Answer Rate', +} +UNCONDITIONAL_PASSING = 100 + + +class ProgressRestriction(object): + def __init__(self, course_key, user, course_module): + self.dict_section_name_by_vertical_name = {} + + self.list_restricted_chapter_name = [] + self.list_restricted_section_name = [] + + self.list_restricted_chapter_index = [] + self.dict_restricted_sections_by_chapter_index = {} + self.dict_restricted_verticals_by_section_name = {} + + if course_module: + student_module_histories = StudentModuleHistory.objects.filter( + student_module__course_id=course_key, + student_module__student_id=user.id, + ) + + passed_restricted_vertical = False + for chapter_idx, chapter in enumerate(course_module.get_display_items()): + if chapter.visible_to_staff_only: + # visible_to_staff_only is not used as condition + # of progress restriction + continue + + if passed_restricted_vertical: + self.list_restricted_chapter_name.append(unicode(chapter.location.name)) + self.list_restricted_chapter_index.append(chapter_idx) + + restricted_sections = [] + + for section_idx, section in enumerate(chapter.get_display_items()): + if section.visible_to_staff_only: + # visible_to_staff_only is not used as condition + # of progress restriction + continue + + if passed_restricted_vertical: + self.list_restricted_section_name.append(unicode(section.location.name)) + restricted_sections.append(section_idx + 1) + + restricted_verticals = [] + + for vertical_idx, vertical in enumerate(section.get_display_items()): + if vertical.visible_to_staff_only: + # visible_to_staff_only is not used as condition + # of progress restriction + continue + + if passed_restricted_vertical: + restricted_verticals.append(vertical_idx) + + is_restricted_vertical = passed_restricted_vertical + if not is_restricted_vertical and vertical.progress_restriction['type'] == PROGRESS_RESTRICTION_TYPE['correct-answer-rate']: + problems = [ + { + 'location': component.location, + 'count': component.get_score()['total'], + 'whole_point_addition': component.whole_point_addition + } for component in vertical.get_children() if hasattr(component, 'category') and component.category == u'problem' + ] + problem_count = sum([p['count'] for p in problems]) + + if problem_count: + problems = filter(lambda p: not p['whole_point_addition'], problems) + correct_count = problem_count - sum([p['count'] for p in problems]) + has_answered_correctly = {} + for sm in student_module_histories.filter(student_module__module_state_key__in=[p['location'] for p in problems]): + st = json.loads(sm.state) + for k in st.get('correct_map', []): + if st['correct_map'][k]['correctness'] == 'correct': + has_answered_correctly[k] = True + correct_count += len(filter(lambda v: v, has_answered_correctly.values())) + + is_restricted_vertical = (100 * correct_count / problem_count) < vertical.progress_restriction.get('passing_mark', 0) + + self.dict_section_name_by_vertical_name[unicode(vertical.location.name)] = unicode(section.location.name) + + if is_restricted_vertical and not passed_restricted_vertical: + passed_restricted_vertical = True + + if restricted_verticals: + self.dict_restricted_verticals_by_section_name[unicode(section.location.name)] = restricted_verticals + + self.dict_restricted_sections_by_chapter_index[chapter_idx] = restricted_sections + + def get_restricted_list_in_section(self, section): + """ + Returns a list of sequence numbers of restricted vertical-blocks in specific section + """ + return self.dict_restricted_verticals_by_section_name.get(unicode(section), []) + + def get_restricted_list_in_same_section(self, vertical_name): + """ + Returns a list of sequence numbers of restricted vertical-blocks + in section including specific vertical-block + """ + return self.get_restricted_list_in_section(self.dict_section_name_by_vertical_name[vertical_name]) if vertical_name in self.dict_section_name_by_vertical_name else [] + + def get_restricted_chapters(self): + """ + Returns a list of restricted chapters' sequence numbers, to use to gray out toc + """ + return self.list_restricted_chapter_index + + def get_restricted_sections(self): + """ + Returns a dict of chapters' numbers as keys and + list of restricted sections' sequence numbers as values + to use to gray out toc + """ + return self.dict_restricted_sections_by_chapter_index + + def is_restricted_chapter(self, chapter): + """ + Returns a boolean if chapter is restricted + when the first vertical-block in the chapter is restricted the chapter is restricted + """ + return chapter in self.list_restricted_chapter_name + + def is_restricted_section(self, section): + """ + Returns a boolean if section is restricted + when the first vertical-block in the section is restricted the section is restricted + """ + return section in self.list_restricted_section_name diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 873a4ec41543..857abc63d02d 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -28,6 +28,7 @@ from capa.xqueue_interface import XQueueInterface from courseware.access import has_access, get_user_role +from courseware.ga_progress_restriction import ProgressRestriction from courseware.masquerade import ( MasqueradingKeyValueStore, filter_displayed_blocks, @@ -49,6 +50,8 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey, CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey +from openedx.core.djangoapps.ga_optional.api import is_available +from openedx.core.djangoapps.ga_optional.models import PROGRESS_RESTRICTION_OPTION_KEY from openedx.core.lib.xblock_utils import ( replace_course_urls, replace_jump_to_id_urls, @@ -58,7 +61,7 @@ request_token as xblock_request_token, ) from student.models import anonymous_id_for_user, user_by_anonymous_id -from student.roles import CourseBetaTesterRole +from student.roles import CourseBetaTesterRole, GaCourseScorerRole, GaGlobalCourseCreatorRole from xblock.core import XBlock from xblock.django.request import django_to_webob_request, webob_to_django_response from xblock_django.user_service import DjangoXBlockUserService @@ -648,7 +651,11 @@ def rebind_noauth_module_to_user(module, real_user): staff_access = has_access(user, 'staff', descriptor, course_id) instructor_access = bool(has_access(user, 'instructor', descriptor, course_id)) if staff_access: - block_wrappers.append(partial(add_staff_markup, user, instructor_access, disable_staff_debug_info)) + # Note: GaGlobalCourseCreator has not access to staff tools (#2150) + if not GaGlobalCourseCreatorRole().has_user(user): + # Note: GaCourseScorer has access to staff tools, but cannot see the studio link (#2150) + block_wrappers.append(partial(add_staff_markup, user, instructor_access, disable_staff_debug_info, + enable_studio_link=not GaCourseScorerRole(course_id).has_user(user))) # These modules store data using the anonymous_student_id as a key. # To prevent loss of data, we will continue to provide old modules with @@ -1017,6 +1024,27 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course try: with tracker.get_tracker().context(tracking_context_name, tracking_context): resp = instance.handle(handler, req, suffix) + + # add list of subsections(sequence numbers) restricted by progress restriction + # to unblock next subsection when passed + staff_access = has_access(request.user, 'staff', course.id) + if (suffix == 'problem_check' and + is_available(PROGRESS_RESTRICTION_OPTION_KEY, course.id) and + not staff_access): + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + course_key, request.user, course, depth=2) + course_module = get_module_for_descriptor( + request.user, request, course, field_data_cache, course.id, course=course + ) + progress_restriction = ProgressRestriction(course.id, request.user, course_module) + + restricted_list = progress_restriction.get_restricted_list_in_same_section(instance.parent.name) + restricted_chapters = progress_restriction.get_restricted_chapters() + restricted_sections = progress_restriction.get_restricted_sections() + resp = append_data_to_webob_response(resp, {'restricted_list': restricted_list, + 'restricted_chapters': restricted_chapters, + 'restricted_sections': restricted_sections}) + if suffix == 'problem_check' \ and course \ and getattr(course, 'entrance_exam_enabled', False) \ diff --git a/lms/djangoapps/courseware/tests/factories.py b/lms/djangoapps/courseware/tests/factories.py index 0f968380cd5e..e887af57b961 100644 --- a/lms/djangoapps/courseware/tests/factories.py +++ b/lms/djangoapps/courseware/tests/factories.py @@ -15,6 +15,8 @@ CourseInstructorRole, CourseStaffRole, CourseBetaTesterRole, + GaCourseScorerRole, + GaGlobalCourseCreatorRole, GaOldCourseViewerStaffRole, GlobalStaff, OrgStaffRole, @@ -132,6 +134,30 @@ def set_staff(self, create, extracted, **kwargs): GaOldCourseViewerStaffRole().add_users(self) +class GaGlobalCourseCreatorFactory(UserFactory): + """ + Returns a User object with GaGlobalCourseCreator members access + """ + last_name = "GaGlobalCourseCreator" + + @factory.post_generation + def set_staff(self, create, extracted, **kwargs): + GaGlobalCourseCreatorRole().add_users(self) + + +class GaCourseScorerFactory(UserFactory): + """ + Returns a User object with GaCourseScorer members access + """ + last_name = "GaCourseScorer" + + @factory.post_generation + def course_key(self, create, extracted, **kwargs): + if extracted is None: + raise ValueError("Must specify a CourseKey for a ga_course_scorer user") + GaCourseScorerRole(extracted).add_users(self) + + class StudentModuleFactory(DjangoModelFactory): class Meta(object): model = StudentModule diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index a23a630187e7..ebc15253445b 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -22,6 +22,8 @@ from courseware.tests.factories import ( BetaTesterFactory, GlobalStaffFactory, + GaCourseScorerFactory, + GaGlobalCourseCreatorFactory, GaOldCourseViewerStaffFactory, InstructorFactory, StaffFactory, @@ -79,6 +81,8 @@ def setUp(self): self.course_instructor = InstructorFactory(course_key=self.course.id) self.staff = GlobalStaffFactory() self.ga_old_course_viewer = GaOldCourseViewerStaffFactory() + self.ga_global_course_creator = GaGlobalCourseCreatorFactory() + self.ga_course_scorer = GaCourseScorerFactory(course_key=self.course.id) def verify_access(self, mock_unit, student_should_have_access, expected_error_type=None): """ Verify the expected result from _has_access_descriptor """ @@ -93,6 +97,12 @@ def verify_access(self, mock_unit, student_should_have_access, expected_error_ty self.assertTrue( access._has_access_descriptor(self.course_staff, 'load', mock_unit, course_key=self.course.id) ) + self.assertTrue( + access._has_access_descriptor(self.ga_global_course_creator, 'load', mock_unit, course_key=self.course.id) + ) + self.assertTrue( + access._has_access_descriptor(self.ga_course_scorer, 'load', mock_unit, course_key=self.course.id) + ) def test_has_staff_access_to_preview_mode(self): """ @@ -129,13 +139,17 @@ def test_has_staff_access_to_preview_mode(self): self.assertTrue( bool(access.has_staff_access_to_preview_mode(self.global_staff, obj=self.course, course_key=course_key)) ) + self.assertTrue( + bool(access.has_staff_access_to_preview_mode(self.ga_course_scorer, obj=self.course, course_key=course_key)) + ) - for user in [self.global_staff, self.course_staff, self.course_instructor]: + for user in [self.global_staff, self.course_staff, self.course_instructor, self.ga_course_scorer]: for obj in modules: self.assertTrue(bool(access.has_staff_access_to_preview_mode(user, obj=obj))) self.assertFalse(bool(access.has_staff_access_to_preview_mode(self.student, obj=obj))) for obj in modules: self.assertFalse(bool(access.has_staff_access_to_preview_mode(self.ga_old_course_viewer, obj=obj))) + self.assertFalse(bool(access.has_staff_access_to_preview_mode(self.ga_global_course_creator, obj=obj))) def test_student_has_access(self): """ @@ -172,6 +186,7 @@ def test_string_has_staff_access_to_preview_mode(self): self.assertFalse(bool(access.has_staff_access_to_preview_mode(self.course_instructor, obj='global'))) self.assertFalse(bool(access.has_staff_access_to_preview_mode(self.student, obj='global'))) self.assertFalse(bool(access.has_staff_access_to_preview_mode(self.ga_old_course_viewer, obj='global'))) + self.assertFalse(bool(access.has_staff_access_to_preview_mode(self.ga_global_course_creator, obj='global'))) @patch('courseware.access.in_preview_mode', Mock(return_value=True)) def test_has_access_with_preview_mode(self): @@ -187,6 +202,10 @@ def test_has_access_with_preview_mode(self): self.assertFalse(bool(access.has_access(self.student, 'load', self.course, course_key=self.course.id))) self.assertFalse(bool(access.has_access(self.ga_old_course_viewer, 'staff', self.course, course_key=self.course.id))) self.assertFalse(bool(access.has_access(self.ga_old_course_viewer, 'load', self.course, course_key=self.course.id))) + self.assertFalse(bool(access.has_access(self.ga_global_course_creator, 'staff', self.course, course_key=self.course.id))) + self.assertFalse(bool(access.has_access(self.ga_global_course_creator, 'load', self.course, course_key=self.course.id))) + self.assertTrue(bool(access.has_access(self.ga_course_scorer, 'staff', self.course, course_key=self.course.id))) + self.assertTrue(bool(access.has_access(self.ga_course_scorer, 'load', self.course, course_key=self.course.id))) # User should be able to preview when masquerade. with patch('courseware.access.is_masquerading_as_student') as mock_masquerade: @@ -200,6 +219,12 @@ def test_has_access_with_preview_mode(self): self.assertFalse( bool(access.has_access(self.ga_old_course_viewer, 'staff', self.course, course_key=self.course.id)) ) + self.assertFalse( + bool(access.has_access(self.ga_global_course_creator, 'staff', self.course, course_key=self.course.id)) + ) + self.assertTrue( + bool(access.has_access(self.ga_course_scorer, 'staff', self.course, course_key=self.course.id)) + ) def test_has_access_to_course(self): self.assertFalse(access._has_access_to_course( @@ -250,6 +275,18 @@ def test_has_access_to_course(self): self.assertFalse(access._has_access_to_course( self.ga_old_course_viewer, 'instructor', self.course.id )) + self.assertFalse(access._has_access_to_course( + self.ga_global_course_creator, 'staff', self.course.id + )) + self.assertFalse(access._has_access_to_course( + self.ga_global_course_creator, 'instructor', self.course.id + )) + self.assertTrue(access._has_access_to_course( + self.ga_course_scorer, 'staff', self.course.id + )) + self.assertFalse(access._has_access_to_course( + self.ga_course_scorer, 'instructor', self.course.id + )) self.assertFalse(access._has_access_to_course( self.student, 'not_staff_or_instructor', self.course.id @@ -257,6 +294,9 @@ def test_has_access_to_course(self): self.assertFalse(access._has_access_to_course( self.ga_old_course_viewer, 'not_staff_or_instructor', self.course.id )) + self.assertFalse(access._has_access_to_course( + self.ga_global_course_creator, 'not_staff_or_instructor', self.course.id + )) def test__has_access_string(self): user = Mock(is_staff=True) @@ -268,17 +308,20 @@ def test__has_access_string(self): self.assertRaises(ValueError, access._has_access_string, user, 'not_staff', 'global') @ddt.data( - ('staff', False, True, False), - ('support', False, True, False), - ('certificates', False, True, False), - ('ga_old_course_view', False, True, True) + ('staff', False, True, False, False), + ('support', False, True, False, False), + ('certificates', False, True, False, False), + ('ga_old_course_view', False, True, True, False), + ('ga_global_course_creator', False, False, False, True), ) @ddt.unpack - def test__has_access_string_some_roles(self, action, expected_student, expected_staff, expected_ga_old_course_viewer): + def test__has_access_string_some_roles(self, action, expected_student, expected_staff, expected_ga_old_course_viewer, expected_ga_global_course_creator): + idx = 0 for (user, expected_response) in ( (self.student, expected_student), (self.staff, expected_staff), - (self.ga_old_course_viewer, expected_ga_old_course_viewer) + (self.ga_old_course_viewer, expected_ga_old_course_viewer), + (self.ga_global_course_creator, expected_ga_global_course_creator) ): self.assertEquals( bool(access._has_access_string(user, action, 'global')), @@ -400,6 +443,14 @@ def test__has_access_course_can_enroll(self): user = StaffFactory.create(course_key=course.id) self.assertTrue(access._has_access_course(user, 'enroll', course)) + # GaGlobalCourseCreator can always enroll even outside the open enrollment period + user = GaGlobalCourseCreatorFactory.create() + self.assertTrue(access._has_access_course(user, 'enroll', course)) + + # GaCourseScorer can always enroll even outside the open enrollment period + user = GaCourseScorerFactory.create(course_key=course.id) + self.assertTrue(access._has_access_course(user, 'enroll', course)) + # Non-staff cannot enroll if it is between the start and end dates and invitation only # and not specifically allowed course = Mock( @@ -437,6 +488,8 @@ def test__catalog_visibility(self): user = UserFactory.create() course_id = SlashSeparatedCourseKey('edX', 'test', '2012_Fall') staff = StaffFactory.create(course_key=course_id) + ga_global_course_creator = GaGlobalCourseCreatorFactory.create() + ga_course_scorer = GaCourseScorerFactory.create(course_key=course_id) course = Mock( id=course_id, @@ -446,6 +499,10 @@ def test__catalog_visibility(self): self.assertTrue(access._has_access_course(user, 'see_about_page', course)) self.assertTrue(access._has_access_course(staff, 'see_in_catalog', course)) self.assertTrue(access._has_access_course(staff, 'see_about_page', course)) + self.assertTrue(access._has_access_course(ga_global_course_creator, 'see_in_catalog', course)) + self.assertTrue(access._has_access_course(ga_global_course_creator, 'see_about_page', course)) + self.assertTrue(access._has_access_course(ga_course_scorer, 'see_in_catalog', course)) + self.assertTrue(access._has_access_course(ga_course_scorer, 'see_about_page', course)) # Now set visibility to just about page course = Mock( @@ -456,6 +513,10 @@ def test__catalog_visibility(self): self.assertTrue(access._has_access_course(user, 'see_about_page', course)) self.assertTrue(access._has_access_course(staff, 'see_in_catalog', course)) self.assertTrue(access._has_access_course(staff, 'see_about_page', course)) + self.assertTrue(access._has_access_course(ga_global_course_creator, 'see_in_catalog', course)) + self.assertTrue(access._has_access_course(ga_global_course_creator, 'see_about_page', course)) + self.assertTrue(access._has_access_course(ga_course_scorer, 'see_in_catalog', course)) + self.assertTrue(access._has_access_course(ga_course_scorer, 'see_about_page', course)) # Now set visibility to none, which means neither in catalog nor about pages course = Mock( @@ -466,6 +527,10 @@ def test__catalog_visibility(self): self.assertFalse(access._has_access_course(user, 'see_about_page', course)) self.assertTrue(access._has_access_course(staff, 'see_in_catalog', course)) self.assertTrue(access._has_access_course(staff, 'see_about_page', course)) + self.assertTrue(access._has_access_course(ga_global_course_creator, 'see_in_catalog', course)) + self.assertTrue(access._has_access_course(ga_global_course_creator, 'see_about_page', course)) + self.assertTrue(access._has_access_course(ga_course_scorer, 'see_in_catalog', course)) + self.assertTrue(access._has_access_course(ga_course_scorer, 'see_about_page', course)) @patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) def test_access_on_course_with_pre_requisites(self): @@ -649,15 +714,17 @@ def setUp(self): fulfill_course_milestone(self.user_completed_pre_requisite, self.course_started.id) self.user_staff = UserFactory.create(is_staff=True) self.user_anonymous = AnonymousUserFactory.create() + self.ga_global_course_creator = GaGlobalCourseCreatorFactory.create() + self.ga_course_scorer = GaCourseScorerFactory.create(course_key=self.course_not_started.id) ENROLL_TEST_DATA = list(itertools.product( - ['user_normal', 'user_staff', 'user_anonymous'], + ['user_normal', 'user_staff', 'user_anonymous', 'ga_global_course_creator', 'ga_course_scorer'], ['enroll'], ['course_default', 'course_started', 'course_not_started', 'course_staff_only'], )) LOAD_TEST_DATA = list(itertools.product( - ['user_normal', 'user_beta_tester', 'user_staff'], + ['user_normal', 'user_beta_tester', 'user_staff', 'ga_global_course_creator', 'ga_course_scorer'], ['load'], ['course_default', 'course_started', 'course_not_started', 'course_staff_only'], )) @@ -669,7 +736,7 @@ def setUp(self): )) PREREQUISITES_TEST_DATA = list(itertools.product( - ['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous'], + ['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous', 'ga_global_course_creator', 'ga_course_scorer'], ['view_courseware_with_prerequisites'], ['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'], )) @@ -693,6 +760,8 @@ def test_course_overview_access(self, user_attr_name, action, course_attr_name): """ user = getattr(self, user_attr_name) course = getattr(self, course_attr_name) + if user_attr_name == 'ga_course_scorer': + user = GaCourseScorerFactory.create(course_key=course.id) course_overview = CourseOverview.get_from_id(course.id) self.assertEqual( diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py index 965c295460b7..a7e10c7f44d8 100644 --- a/lms/djangoapps/courseware/tests/test_date_summary.py +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -4,9 +4,9 @@ import ddt from django.core.urlresolvers import reverse -import freezegun +from freezegun import freeze_time from nose.plugins.attrib import attr -import pytz +from pytz import utc from course_modes.tests.factories import CourseModeFactory from course_modes.models import CourseMode @@ -20,6 +20,7 @@ VerifiedUpgradeDeadlineDate, ) from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory @@ -46,7 +47,7 @@ def setup_course_and_user( verification_status=None, ): """Set up the course and user for this test.""" - now = datetime.now(pytz.UTC) + now = datetime.now(utc) self.course = CourseFactory.create( # pylint: disable=attribute-defined-outside-init start=now + timedelta(days=days_till_start) ) @@ -140,21 +141,49 @@ def test_date_summary(self): ## TodaysDate - @freezegun.freeze_time('2015-01-02') - def test_todays_date(self): + def _today_date_helper(self, expected_display_date): + """ + Helper function to test that today's date block renders correctly + and displays the correct time, accounting for daylight savings + """ self.setup_course_and_user() + set_user_preference(self.user, "time_zone", "America/Los_Angeles") block = TodaysDate(self.course, self.user) self.assertTrue(block.is_enabled) - self.assertEqual(block.date, datetime.now(pytz.UTC)) - self.assertEqual(block.title, 'Today is Jan 02, 2015 (00:00 UTC)') + self.assertEqual(block.date, datetime.now(utc)) + self.assertEqual(block.title, 'Today is {date}'.format(date=expected_display_date)) self.assertNotIn('date-summary-date', block.render()) - @freezegun.freeze_time('2015-01-02') + @freeze_time('2015-11-01 08:59:00') + def test_todays_date_time_zone_daylight(self): + """ + Test today's date block displays correctly during + daylight savings hours + """ + self._today_date_helper('Nov 01, 2015 (01:59 PDT)') + + @freeze_time('2015-11-01 09:00:00') + def test_todays_date_time_zone_normal(self): + """ + Test today's date block displays correctly during + normal daylight hours + """ + self._today_date_helper('Nov 01, 2015 (01:00 PST)') + + @freeze_time('2015-01-02') def test_todays_date_render(self): self.setup_course_and_user() block = TodaysDate(self.course, self.user) self.assertIn('Jan 02, 2015', block.render()) + @freeze_time('2015-01-02') + def test_todays_date_render_time_zone(self): + self.setup_course_and_user() + set_user_preference(self.user, "time_zone", "America/Los_Angeles") + block = TodaysDate(self.course, self.user) + # Today is 'Jan 01, 2015' because of time zone offset + self.assertIn('Jan 01, 2015', block.render()) + ## CourseStartDate def test_course_start_date(self): @@ -162,15 +191,23 @@ def test_course_start_date(self): block = CourseStartDate(self.course, self.user) self.assertEqual(block.date, self.course.start) - @freezegun.freeze_time('2015-01-02') + @freeze_time('2015-01-02') def test_start_date_render(self): self.setup_course_and_user() block = CourseStartDate(self.course, self.user) self.assertIn('in 1 day - Jan 03, 2015', block.render()) + @freeze_time('2015-01-02') + def test_start_date_render_time_zone(self): + self.setup_course_and_user() + set_user_preference(self.user, "time_zone", "America/Los_Angeles") + block = CourseStartDate(self.course, self.user) + # Jan 02 is in 1 day because of time zone offset + self.assertIn('in 1 day - Jan 02, 2015', block.render()) + ## CourseEndDate - def test_course_end_date_during_course(self): + def test_course_end_date_for_certificate_eligible_mode(self): self.setup_course_and_user(days_till_start=-1) block = CourseEndDate(self.course, self.user) self.assertEqual( @@ -178,6 +215,14 @@ def test_course_end_date_during_course(self): 'To earn a certificate, you must complete all requirements before this date.' ) + def test_course_end_date_for_non_certificate_eligible_mode(self): + self.setup_course_and_user(days_till_start=-1, enrollment_mode=CourseMode.AUDIT) + block = CourseEndDate(self.course, self.user) + self.assertEqual( + block.description, + 'After this date, course content will be archived.' + ) + def test_course_end_date_after_course(self): self.setup_course_and_user(days_till_start=-2, days_till_end=-1) block = CourseEndDate(self.course, self.user) @@ -188,11 +233,11 @@ def test_course_end_date_after_course(self): ## VerifiedUpgradeDeadlineDate - @freezegun.freeze_time('2015-01-02') + @freeze_time('2015-01-02') def test_verified_upgrade_deadline_date(self): self.setup_course_and_user(days_till_upgrade_deadline=1) block = VerifiedUpgradeDeadlineDate(self.course, self.user) - self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=1)) + self.assertEqual(block.date, datetime.now(utc) + timedelta(days=1)) self.assertEqual(block.link, reverse('verify_student_upgrade_and_verify', args=(self.course.id,))) def test_without_upgrade_deadline(self): @@ -212,13 +257,13 @@ def test_no_verified_enrollment(self): block = VerificationDeadlineDate(self.course, self.user) self.assertFalse(block.is_enabled) - @freezegun.freeze_time('2015-01-02') + @freeze_time('2015-01-02') def test_verification_deadline_date_upcoming(self): self.setup_course_and_user(days_till_start=-1) block = VerificationDeadlineDate(self.course, self.user) self.assertEqual(block.css_class, 'verification-deadline-upcoming') self.assertEqual(block.title, 'Verification Deadline') - self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=14)) + self.assertEqual(block.date, datetime.now(utc) + timedelta(days=14)) self.assertEqual( block.description, 'You must successfully complete verification before this date to qualify for a Verified Certificate.' @@ -226,13 +271,13 @@ def test_verification_deadline_date_upcoming(self): self.assertEqual(block.link_text, 'Verify My Identity') self.assertEqual(block.link, reverse('verify_student_verify_now', args=(self.course.id,))) - @freezegun.freeze_time('2015-01-02') + @freeze_time('2015-01-02') def test_verification_deadline_date_retry(self): self.setup_course_and_user(days_till_start=-1, verification_status='denied') block = VerificationDeadlineDate(self.course, self.user) self.assertEqual(block.css_class, 'verification-deadline-retry') self.assertEqual(block.title, 'Verification Deadline') - self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=14)) + self.assertEqual(block.date, datetime.now(utc) + timedelta(days=14)) self.assertEqual( block.description, 'You must successfully complete verification before this date to qualify for a Verified Certificate.' @@ -240,7 +285,7 @@ def test_verification_deadline_date_retry(self): self.assertEqual(block.link_text, 'Retry Verification') self.assertEqual(block.link, reverse('verify_student_reverify')) - @freezegun.freeze_time('2015-01-02') + @freeze_time('2015-01-02') def test_verification_deadline_date_denied(self): self.setup_course_and_user( days_till_start=-10, @@ -250,10 +295,42 @@ def test_verification_deadline_date_denied(self): block = VerificationDeadlineDate(self.course, self.user) self.assertEqual(block.css_class, 'verification-deadline-passed') self.assertEqual(block.title, 'Missed Verification Deadline') - self.assertEqual(block.date, datetime.now(pytz.UTC) + timedelta(days=-1)) + self.assertEqual(block.date, datetime.now(utc) + timedelta(days=-1)) self.assertEqual( block.description, "Unfortunately you missed this course's deadline for a successful verification." ) self.assertEqual(block.link_text, 'Learn More') self.assertEqual(block.link, '') + + @freeze_time('2015-01-02') + @ddt.data( + (-1, '1 day ago - Jan 01, 2015'), + (1, 'in 1 day - Jan 03, 2015') + ) + @ddt.unpack + def test_render_date_string_past(self, delta, expected_date_string): + self.setup_course_and_user( + days_till_start=-10, + verification_status='denied', + days_till_verification_deadline=delta, + ) + block = VerificationDeadlineDate(self.course, self.user) + self.assertEqual(block.get_context()['date'], expected_date_string) + + @freeze_time('2015-01-02') + @ddt.data( + # dates reflected from Jan 01, 2015 because of time zone offset + (-1, '1 day ago - Dec 31, 2014'), + (1, 'in 1 day - Jan 02, 2015') + ) + @ddt.unpack + def test_render_date_string_time_zone(self, delta, expected_date_string): + self.setup_course_and_user( + days_till_start=-10, + verification_status='denied', + days_till_verification_deadline=delta, + ) + set_user_preference(self.user, "time_zone", "America/Los_Angeles") + block = VerificationDeadlineDate(self.course, self.user) + self.assertEqual(block.get_context()['date'], expected_date_string) diff --git a/lms/djangoapps/courseware/tests/test_ga_access.py b/lms/djangoapps/courseware/tests/test_ga_access.py index 3abc2a9c7e4d..528e072b04bd 100644 --- a/lms/djangoapps/courseware/tests/test_ga_access.py +++ b/lms/djangoapps/courseware/tests/test_ga_access.py @@ -8,6 +8,8 @@ from courseware.ga_access import is_terminated from courseware.tests.factories import ( BetaTesterFactory, + GaCourseScorerFactory, + GaGlobalCourseCreatorFactory, GaOldCourseViewerStaffFactory, StaffFactory, UserFactory, @@ -28,6 +30,8 @@ def _create_enrolled_users(self, course, enroll_days_ago=0): course_staff = StaffFactory(course_key=course.id) course_beta_tester = BetaTesterFactory(course_key=course.id) student = UserFactory() + global_course_creator = GaGlobalCourseCreatorFactory() + course_scorer = GaCourseScorerFactory(course_key=course.id) # enroll to course enroll_global_staff = CourseEnrollment.enroll(global_staff, course.id) @@ -45,8 +49,14 @@ def _create_enrolled_users(self, course, enroll_days_ago=0): enroll_student = CourseEnrollment.enroll(student, course.id) enroll_student.created = datetime.now() - timedelta(days=enroll_days_ago) enroll_student.save() + enroll_global_course_creator = CourseEnrollment.enroll(global_course_creator, course.id) + enroll_global_course_creator.created = datetime.now() - timedelta(days=enroll_days_ago) + enroll_global_course_creator.save() + enroll_course_scorer = CourseEnrollment.enroll(course_scorer, course.id) + enroll_course_scorer.created = datetime.now() - timedelta(days=enroll_days_ago) + enroll_course_scorer.save() - return global_staff, old_course_viewer, course_staff, course_beta_tester, student + return global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer def setUp(self): super(IsTerminatedTestCase, self).setUp() @@ -71,7 +81,7 @@ def test_self_paced_course_after_course_registration_date(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -79,6 +89,8 @@ def test_self_paced_course_after_course_registration_date(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertFalse(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) def test_self_paced_course_after_self_pace_course_finished(self): """ @@ -100,7 +112,7 @@ def test_self_paced_course_after_self_pace_course_finished(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users( + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users( course=course, enroll_days_ago=20) # assert is_terminated @@ -109,6 +121,8 @@ def test_self_paced_course_after_self_pace_course_finished(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertTrue(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) def test_self_paced_course_after_course_closed(self): """ @@ -130,7 +144,7 @@ def test_self_paced_course_after_course_closed(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -138,6 +152,8 @@ def test_self_paced_course_after_course_closed(self): self.assertTrue(is_terminated(course_overview, course_staff)) self.assertTrue(is_terminated(course_overview, course_beta_tester)) self.assertTrue(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertTrue(is_terminated(course_overview, course_scorer)) def test_self_paced_course_after_course_start(self): """ @@ -159,7 +175,7 @@ def test_self_paced_course_after_course_start(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -167,6 +183,8 @@ def test_self_paced_course_after_course_start(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertFalse(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) def test_self_paced_course_before_course_start(self): """ @@ -188,7 +206,7 @@ def test_self_paced_course_before_course_start(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -196,6 +214,8 @@ def test_self_paced_course_before_course_start(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertFalse(is_terminated(course_overview, student)) # note + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) def test_instructor_paced_course_after_registration_close(self): """ @@ -216,7 +236,7 @@ def test_instructor_paced_course_after_registration_close(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -224,6 +244,8 @@ def test_instructor_paced_course_after_registration_close(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertFalse(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) def test_instructor_paced_course_after_course_closed(self): """ @@ -244,7 +266,7 @@ def test_instructor_paced_course_after_course_closed(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -252,6 +274,8 @@ def test_instructor_paced_course_after_course_closed(self): self.assertTrue(is_terminated(course_overview, course_staff)) self.assertTrue(is_terminated(course_overview, course_beta_tester)) self.assertTrue(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertTrue(is_terminated(course_overview, course_scorer)) def test_instructor_paced_course_after_course_finished(self): """ @@ -272,7 +296,7 @@ def test_instructor_paced_course_after_course_finished(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -280,6 +304,8 @@ def test_instructor_paced_course_after_course_finished(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertFalse(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) def test_instructor_paced_course_after_course_start(self): """ @@ -300,7 +326,7 @@ def test_instructor_paced_course_after_course_start(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -308,6 +334,8 @@ def test_instructor_paced_course_after_course_start(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertFalse(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) def test_instructor_paced_course_before_course_start(self): """ @@ -328,7 +356,7 @@ def test_instructor_paced_course_before_course_start(self): course_overview = CourseOverview.get_from_id(course.id) # create users and enroll to course - global_staff, old_course_viewer, course_staff, course_beta_tester, student = self._create_enrolled_users(course) + global_staff, old_course_viewer, course_staff, course_beta_tester, student, global_course_creator, course_scorer = self._create_enrolled_users(course) # assert is_terminated self.assertFalse(is_terminated(course_overview, global_staff)) @@ -336,3 +364,5 @@ def test_instructor_paced_course_before_course_start(self): self.assertFalse(is_terminated(course_overview, course_staff)) self.assertFalse(is_terminated(course_overview, course_beta_tester)) self.assertFalse(is_terminated(course_overview, student)) + self.assertFalse(is_terminated(course_overview, global_course_creator)) + self.assertFalse(is_terminated(course_overview, course_scorer)) diff --git a/lms/djangoapps/courseware/tests/test_ga_middleware.py b/lms/djangoapps/courseware/tests/test_ga_middleware.py index 50b8c56d02ad..54c2ba4d3fac 100644 --- a/lms/djangoapps/courseware/tests/test_ga_middleware.py +++ b/lms/djangoapps/courseware/tests/test_ga_middleware.py @@ -9,6 +9,7 @@ from courseware.ga_middleware import CourseTerminatedCheckMiddleware, CustomLogoMiddleware from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase from openedx.core.djangoapps.ga_optional.models import CourseOptionalConfiguration +from student.roles import GaCourseScorerRole, GaGlobalCourseCreatorRole from student.tests.factories import CourseAccessRoleFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -135,6 +136,104 @@ def test_self_paced_course_closed(self, is_blocked, is_staff, is_old_course_view self.assertEqual(mock_is_course_closed.call_count, 1) +class CourseTerminatedCheckMiddlewareTestWithGaGlobalCourseCreator(LoginEnrollmentTestCase, ModuleStoreTestCase): + """ + Tests for CourseTerminatedCheckMiddleware. + """ + + def setUp(self): + super(CourseTerminatedCheckMiddlewareTestWithGaGlobalCourseCreator, self).setUp() + self.course = CourseFactory.create(start=timezone.now() - timedelta(days=10)) + + def _create_request(self, path): + self.setup_user() + GaGlobalCourseCreatorRole().add_users(self.user) + self.enroll(self.course) + request = get_request_for_user(self.user) + request.path = path.format(unicode(self.course.id)) + return request + + def test_course_opened(self): + """ + Tests that the opened course always does not block the access. + """ + request = self._create_request('/courses/{}/courseware/') + response = CourseTerminatedCheckMiddleware().process_request(request) + self.assertIsNone(response) + + def test_course_terminated(self): + """ + Tests that the terminated course the access by GaGlobalCourseCreator. + """ + self.course.terminate_start = timezone.now() - timedelta(days=1) + self.update_course(self.course, self.user.id) + + request = self._create_request('/courses/{}/courseware/') + response = CourseTerminatedCheckMiddleware().process_request(request) + self.assertIsNone(response) + + @patch('openedx.core.djangoapps.ga_self_paced.api.is_course_closed', return_value=True) + def test_self_paced_course_closed(self, mock_is_course_closed): + """ + Tests that the self-paced closed course the access by GaGlobalCourseCreator. + """ + self.course.self_paced = True + self.update_course(self.course, self.user.id) + + request = self._create_request('/courses/{}/courseware/') + response = CourseTerminatedCheckMiddleware().process_request(request) + self.assertIsNone(response) + + +class CourseTerminatedCheckMiddlewareTestWithGaCourseScorer(LoginEnrollmentTestCase, ModuleStoreTestCase): + """ + Tests for CourseTerminatedCheckMiddleware. + """ + + def setUp(self): + super(CourseTerminatedCheckMiddlewareTestWithGaCourseScorer, self).setUp() + self.course = CourseFactory.create(start=timezone.now() - timedelta(days=10)) + + def _create_request(self, path): + self.setup_user() + GaCourseScorerRole(self.course.id).add_users(self.user) + self.enroll(self.course) + request = get_request_for_user(self.user) + request.path = path.format(unicode(self.course.id)) + return request + + def test_course_opened(self): + """ + Tests that the opened course always does not block the access. + """ + request = self._create_request('/courses/{}/courseware/') + response = CourseTerminatedCheckMiddleware().process_request(request) + self.assertIsNone(response) + + def test_course_terminated(self): + """ + Tests that the terminated course block the access by GaCourseScorer. + """ + self.course.terminate_start = timezone.now() - timedelta(days=1) + self.update_course(self.course, self.user.id) + + request = self._create_request('/courses/{}/courseware/') + response = CourseTerminatedCheckMiddleware().process_request(request) + self.assertEquals(response.status_code, 302) + + @patch('openedx.core.djangoapps.ga_self_paced.api.is_course_closed', return_value=True) + def test_self_paced_course_closed(self, mock_is_course_closed): + """ + Tests that the self-paced closed course the access by GaCourseScorer. + """ + self.course.self_paced = True + self.update_course(self.course, self.user.id) + + request = self._create_request('/courses/{}/courseware/') + response = CourseTerminatedCheckMiddleware().process_request(request) + self.assertIsNone(response) + + @ddt.ddt class CourseTerminatedCheckViewTest(LoginEnrollmentTestCase, ModuleStoreTestCase): """ @@ -217,6 +316,80 @@ def test_self_paced_course_closed_not_logged_in(self): self._assert_redirect_login(response) +class CourseTerminatedCheckViewTestWithGaGlobalCourseCreator(LoginEnrollmentTestCase, ModuleStoreTestCase): + """ + Tests for the courseware view to confirm the execute CourseTerminatedCheckMiddleware. + """ + + def setUp(self): + super(CourseTerminatedCheckViewTestWithGaGlobalCourseCreator, self).setUp() + + self.course = CourseFactory.create(start=timezone.now() - timedelta(days=10)) + + def _access_page(self): + self.setup_user() + GaGlobalCourseCreatorRole().add_users(self.user) + self.enroll(self.course) + path = '/courses/{}/progress'.format(unicode(self.course.id)) + return self.client.get(path) + + def test_course_opened(self): + response = self._access_page() + self.assertEqual(response.status_code, 200) + + def test_course_terminated(self): + self.course.terminate_start = timezone.now() - timedelta(days=1) + self.update_course(self.course, self.user.id) + + response = self._access_page() + self.assertEqual(response.status_code, 200) + + @patch('openedx.core.djangoapps.ga_self_paced.api.is_course_closed', return_value=True) + def test_self_paced_course_closed(self, mock_is_course_closed): + self.course.self_paced = True + self.update_course(self.course, self.user.id) + + response = self._access_page() + self.assertEqual(response.status_code, 200) + + +class CourseTerminatedCheckViewTestWithGaCourseScorer(LoginEnrollmentTestCase, ModuleStoreTestCase): + """ + Tests for the courseware view to confirm the execute CourseTerminatedCheckMiddleware. + """ + + def setUp(self): + super(CourseTerminatedCheckViewTestWithGaCourseScorer, self).setUp() + + self.course = CourseFactory.create(start=timezone.now() - timedelta(days=10)) + + def _access_page(self): + self.setup_user() + GaCourseScorerRole(self.course.id).add_users(self.user) + self.enroll(self.course) + path = '/courses/{}/progress'.format(unicode(self.course.id)) + return self.client.get(path) + + def test_course_opened(self): + response = self._access_page() + self.assertEqual(response.status_code, 200) + + def test_course_terminated(self): + self.course.terminate_start = timezone.now() - timedelta(days=1) + self.update_course(self.course, self.user.id) + + response = self._access_page() + self.assertEqual(response.status_code, 302) + + @patch('openedx.core.djangoapps.ga_self_paced.api.is_course_closed', return_value=True) + def test_self_paced_course_closed(self, mock_is_course_closed): + self.course.self_paced = True + self.update_course(self.course, self.user.id) + + response = self._access_page() + self.assertEqual(response.status_code, 200) + + class CustomLogoMiddlewareTest(LoginEnrollmentTestCase, ModuleStoreTestCase): def setUp(self): diff --git a/lms/djangoapps/courseware/tests/test_ga_module_render.py b/lms/djangoapps/courseware/tests/test_ga_module_render.py new file mode 100644 index 000000000000..271b06965c5d --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_ga_module_render.py @@ -0,0 +1,451 @@ +# -*- coding: utf-8 -*- +""" +Test for lms courseware app, module render unit +""" +import ddt +import json +from nose.plugins.attrib import attr +from mock import Mock, patch + +from django.test.client import RequestFactory +from capa.tests.response_xml_factory import OptionResponseXMLFactory +from courseware import module_render as render +from courseware.model_data import FieldDataCache +from courseware.tests.factories import ( + GaCourseScorerFactory, + GaGlobalCourseCreatorFactory, + GlobalStaffFactory, + StudentModuleFactory, + UserFactory, +) +from courseware.tests.test_ga_progress_restriction import ProgressRestrictionTestBase +from lms.djangoapps.lms_xblock.runtime import quote_slashes +from openedx.core.djangoapps.ga_optional.models import CourseOptionalConfiguration +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory +from xmodule.x_module import XModuleDescriptor, XModule, STUDENT_VIEW, CombinedSystem + + +class LMSXBlockServiceBindingTestWithGaGlobalCourseCreator(ModuleStoreTestCase): + """ + Tests that the LMS Module System (XBlock Runtime) provides an expected set of services. + """ + + def setUp(self): + """ + Set up the user and other fields that will be used to instantiate the runtime. + """ + super(LMSXBlockServiceBindingTestWithGaGlobalCourseCreator, self).setUp() + self.course = CourseFactory.create() + self._set_up_user() + self.student_data = Mock() + self.track_function = Mock() + self.xqueue_callback_url_prefix = Mock() + self.request_token = Mock() + + def _set_up_user(self): + self.user = GaGlobalCourseCreatorFactory() + + def _assert_runtime(self, runtime): + # pylint: disable=no-member + self.assertFalse(runtime.user_is_staff) + self.assertFalse(runtime.user_is_admin) + self.assertFalse(runtime.user_is_beta_tester) + self.assertEqual(runtime.days_early_for_beta, 5) + + def test_is_staff(self): + """ + Tests that the beta tester fields are set on LMS runtime. + """ + descriptor = ItemFactory(category="pure", parent=self.course) + descriptor.days_early_for_beta = 5 + runtime, _ = render.get_module_system_for_user( + self.user, + self.student_data, + descriptor, + self.course.id, + self.track_function, + self.xqueue_callback_url_prefix, + self.request_token, + course=self.course + ) + + self._assert_runtime(runtime) + + +class LMSXBlockServiceBindingTestWithGaCourseScorer(LMSXBlockServiceBindingTestWithGaGlobalCourseCreator): + """ + Tests that the LMS Module System (XBlock Runtime) provides an expected set of services. + """ + + def _set_up_user(self): + self.user = GaCourseScorerFactory(course_key=self.course.id) + + def _assert_runtime(self, runtime): + self.assertTrue(runtime.user_is_staff) + self.assertFalse(runtime.user_is_admin) + self.assertFalse(runtime.user_is_beta_tester) + self.assertEqual(runtime.days_early_for_beta, 5) + + +class MongoViewInStudioWithRoleMixIn(ModuleStoreTestCase): + """Test the 'View in Studio' link visibility in a mongo backed course.""" + + def _set_up_user(self): + """ Set up the user and request that will be used. """ + raise NotImplementedError + + def _get_module(self, course_id, descriptor, location): + """ + Get the module from the course from which to pattern match (or not) the 'View in Studio' buttons + """ + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + course_id, + self.user, + descriptor + ) + + return render.get_module( + self.user, + self.request, + location, + field_data_cache, + ) + + def setup_mongo_course(self, course_edit_method='Studio'): + """ Create a mongo backed course. """ + self.course = CourseFactory.create( + course_edit_method=course_edit_method + ) + + descriptor = ItemFactory.create( + category='vertical', + parent_location=self.course.location, + ) + + child_descriptor = ItemFactory.create( + category='vertical', + parent_location=descriptor.location + ) + + self._set_up_user() + self.request = RequestFactory().get('/') + self.request.user = self.user + self.request.session = {} + + self.module = self._get_module(self.course.id, descriptor, descriptor.location) + + # pylint: disable=attribute-defined-outside-init + self.child_module = self._get_module(self.course.id, child_descriptor, child_descriptor.location) + + +class MongoViewInStudioTestWithGaGlobalCourseCreator(MongoViewInStudioWithRoleMixIn): + """Test the 'View in Studio' link visibility in a mongo backed course.""" + + def _set_up_user(self): + """ Set up the user and request that will be used. """ + self.user = GaGlobalCourseCreatorFactory() + + def test_view_in_studio_link_studio_course(self): + """Regular Studio courses should see 'View in Studio' links.""" + self.setup_mongo_course() + result_fragment = self.module.render(STUDENT_VIEW) + self.assertNotIn('View Unit in Studio', result_fragment.content) + + def test_view_in_studio_link_only_in_top_level_vertical(self): + """Regular Studio courses should not see 'View in Studio' for child verticals of verticals.""" + self.setup_mongo_course() + # Render the parent vertical, then check that there is only a single "View Unit in Studio" link. + result_fragment = self.module.render(STUDENT_VIEW) + # The single "View Unit in Studio" link should appear before the first xmodule vertical definition. + parts = result_fragment.content.split('data-block-type="vertical"') + self.assertEqual(3, len(parts), "Did not find two vertical blocks") + self.assertNotIn('View Unit in Studio', parts[0]) + self.assertNotIn('View Unit in Studio', parts[1]) + self.assertNotIn('View Unit in Studio', parts[2]) + + def test_view_in_studio_link_xml_authored(self): + """Courses that change 'course_edit_method' setting can hide 'View in Studio' links.""" + self.setup_mongo_course(course_edit_method='XML') + result_fragment = self.module.render(STUDENT_VIEW) + self.assertNotIn('View Unit in Studio', result_fragment.content) + + +class MongoViewInStudioTestWithGaCourseScorer(MongoViewInStudioWithRoleMixIn): + """Test the 'View in Studio' link visibility in a mongo backed course.""" + + def _set_up_user(self): + """ Set up the user and request that will be used. """ + self.user = GaCourseScorerFactory(course_key=self.course.id) + + def test_view_in_studio_link_studio_course(self): + """Regular Studio courses should see 'View in Studio' links.""" + self.setup_mongo_course() + result_fragment = self.module.render(STUDENT_VIEW) + self.assertNotIn('View Unit in Studio', result_fragment.content) + + def test_view_in_studio_link_only_in_top_level_vertical(self): + """Regular Studio courses should not see 'View in Studio' for child verticals of verticals.""" + self.setup_mongo_course() + # Render the parent vertical, then check that there is only a single "View Unit in Studio" link. + result_fragment = self.module.render(STUDENT_VIEW) + # The single "View Unit in Studio" link should appear before the first xmodule vertical definition. + parts = result_fragment.content.split('data-block-type="vertical"') + self.assertEqual(3, len(parts), "Did not find two vertical blocks") + self.assertNotIn('View Unit in Studio', parts[0]) + self.assertNotIn('View Unit in Studio', parts[1]) + self.assertNotIn('View Unit in Studio', parts[2]) + + def test_view_in_studio_link_xml_authored(self): + """Courses that change 'course_edit_method' setting can hide 'View in Studio' links.""" + self.setup_mongo_course(course_edit_method='XML') + result_fragment = self.module.render(STUDENT_VIEW) + self.assertNotIn('View Unit in Studio', result_fragment.content) + + +class TestStaffDebugInfoWithRoleMixIn(ModuleStoreTestCase): + def _set_up_user(self): + raise NotImplementedError + + def setUp(self): + super(TestStaffDebugInfoWithRoleMixIn, self).setUp() + self.course = CourseFactory.create() + self._set_up_user() + self.request = RequestFactory().get('/') + self.request.user = self.user + self.request.session = {} + + problem_xml = OptionResponseXMLFactory().build_xml( + question_text='The correct answer is Correct', + num_inputs=2, + weight=2, + options=['Correct', 'Incorrect'], + correct_option='Correct' + ) + self.descriptor = ItemFactory.create( + category='problem', + data=problem_xml, + display_name='Option Response Problem' + ) + + self.location = self.descriptor.location + self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + self.user, + self.descriptor + ) + + +@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_DEBUG_INFO_TO_STAFF': True, 'DISPLAY_HISTOGRAMS_TO_STAFF': True}) +@patch('courseware.module_render.has_access', Mock(return_value=True, autospec=True)) +class TestStaffDebugInfoWithGaGlobalCourseCreator(TestStaffDebugInfoWithRoleMixIn): + """Tests to verify that Staff Debug Info panel and histograms are displayed to staff.""" + + def _set_up_user(self): + self.user = GaGlobalCourseCreatorFactory.create() + + @patch.dict('django.conf.settings.FEATURES', {'DISPLAY_DEBUG_INFO_TO_STAFF': False}) + def test_staff_debug_info_disabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertNotIn('Staff Debug', result_fragment.content) + + def test_staff_debug_info_enabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertNotIn('Staff Debug', result_fragment.content) + + @patch.dict('django.conf.settings.FEATURES', {'DISPLAY_HISTOGRAMS_TO_STAFF': False}) + def test_histogram_disabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertNotIn('histogram', result_fragment.content) + self.assertNotIn('Staff Debug', result_fragment.content) + + def test_histogram_enabled_for_unscored_xmodules(self): + """Histograms should not display for xmodules which are not scored.""" + + html_descriptor = ItemFactory.create( + category='html', + data='Here are some course details.' + ) + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + self.user, + self.descriptor + ) + with patch('openedx.core.lib.xblock_utils.grade_histogram') as mock_grade_histogram: + mock_grade_histogram.return_value = [] + module = render.get_module( + self.user, + self.request, + html_descriptor.location, + field_data_cache, + ) + module.render(STUDENT_VIEW) + self.assertFalse(mock_grade_histogram.called) + + def test_histogram_enabled_for_scored_xmodules(self): + """Histograms should display for xmodules which are scored.""" + + StudentModuleFactory.create( + course_id=self.course.id, + module_state_key=self.location, + student=UserFactory(), + grade=1, + max_grade=1, + state="{}", + ) + with patch('openedx.core.lib.xblock_utils.grade_histogram') as mock_grade_histogram: + mock_grade_histogram.return_value = [] + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertFalse(mock_grade_histogram.called) + self.assertNotIn('Staff Debug', result_fragment.content) + + +@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_DEBUG_INFO_TO_STAFF': True, 'DISPLAY_HISTOGRAMS_TO_STAFF': True}) +@patch('courseware.module_render.has_access', Mock(return_value=True, autospec=True)) +class TestStaffDebugInfoWithGaCourseScorer(TestStaffDebugInfoWithRoleMixIn): + """Tests to verify that Staff Debug Info panel and histograms are displayed to staff.""" + + def _set_up_user(self): + self.user = GaCourseScorerFactory.create(course_key=self.course.id) + + @patch.dict('django.conf.settings.FEATURES', {'DISPLAY_DEBUG_INFO_TO_STAFF': False}) + def test_staff_debug_info_disabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertNotIn('Staff Debug', result_fragment.content) + + def test_staff_debug_info_enabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertIn('Staff Debug', result_fragment.content) + + @patch.dict('django.conf.settings.FEATURES', {'DISPLAY_HISTOGRAMS_TO_STAFF': False}) + def test_histogram_disabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertNotIn('histogram', result_fragment.content) + self.assertIn('Staff Debug', result_fragment.content) + + def test_histogram_enabled_for_unscored_xmodules(self): + """Histograms should not display for xmodules which are not scored.""" + + html_descriptor = ItemFactory.create( + category='html', + data='Here are some course details.' + ) + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + self.user, + self.descriptor + ) + with patch('openedx.core.lib.xblock_utils.grade_histogram') as mock_grade_histogram: + mock_grade_histogram.return_value = [] + module = render.get_module( + self.user, + self.request, + html_descriptor.location, + field_data_cache, + ) + module.render(STUDENT_VIEW) + self.assertFalse(mock_grade_histogram.called) + + def test_histogram_enabled_for_scored_xmodules(self): + """Histograms should display for xmodules which are scored.""" + + StudentModuleFactory.create( + course_id=self.course.id, + module_state_key=self.location, + student=UserFactory(), + grade=1, + max_grade=1, + state="{}", + ) + with patch('openedx.core.lib.xblock_utils.grade_histogram') as mock_grade_histogram: + mock_grade_histogram.return_value = [] + module = render.get_module( + self.user, + self.request, + self.location, + self.field_data_cache, + ) + result_fragment = module.render(STUDENT_VIEW) + self.assertTrue(mock_grade_histogram.called) + self.assertIn('Staff Debug', result_fragment.content) + + +@attr('shard_1') +class TestModuleTrackingContextWithProgressRestriction(ProgressRestrictionTestBase): + """ + Ensure correct tracking information is included in events emitted during XBlock callback handling. + """ + def setUp(self): + super(TestModuleTrackingContextWithProgressRestriction, self).setUp() + + self.request = RequestFactory().get('/') + self.request.user = self.user + self.request.session = {} + + def test_progress_restriction_info(self): + self.set_course_optional_setting() + content = self.submit_problem(self.problem1, {'2_1': 'Correct'}) + self.assertIn('restricted_list', content) + self.assertIn('restricted_chapters', content) + self.assertIn('restricted_sections', content) + + def test_progress_restriction_info_before_answer_correctly(self): + self.set_course_optional_setting() + content = self.submit_problem(self.problem1, {'2_2': 'Incorrect'}) + content_json = json.loads(content) + self.assertEqual(content_json['restricted_list'], [3]) + self.assertEqual(content_json['restricted_chapters'], [1, 2]) + self.assertEqual(content_json['restricted_sections'], {u'0': [2], u'1': [1, 2], u'2': [1, 2]}) + + def test_progress_restriction_info_after_answer_correctly(self): + self.set_course_optional_setting() + self.submit_problem(self.problem1, {'2_1': 'Correct'}) + self.submit_problem(self.problem3, {'2_1': 'Correct'}) + self.submit_problem(self.problem4, {'2_1': 'Correct'}) + content = self.submit_problem(self.problem1, {'2_1': 'Correct'}) + content_json = json.loads(content) + self.assertEqual(content_json['restricted_list'], []) + self.assertEqual(content_json['restricted_chapters'], []) + self.assertEqual(content_json['restricted_sections'], {u'0': [], u'1': [], u'2': []}) diff --git a/lms/djangoapps/courseware/tests/test_ga_progress_restriction.py b/lms/djangoapps/courseware/tests/test_ga_progress_restriction.py new file mode 100644 index 000000000000..f68264160b4d --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_ga_progress_restriction.py @@ -0,0 +1,341 @@ +import ddt +from capa.tests.response_xml_factory import OptionResponseXMLFactory +from courseware.ga_progress_restriction import ProgressRestriction +from courseware.model_data import FieldDataCache +from courseware.module_render import get_module_for_descriptor +from courseware.tests.helpers import LoginEnrollmentTestCase +from django.core.urlresolvers import reverse +from django.test.client import RequestFactory +from lms.djangoapps.lms_xblock.runtime import quote_slashes +from nose.plugins.attrib import attr +from openedx.core.djangoapps.ga_optional.models import CourseOptionalConfiguration +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +@attr('shard_1') +@ddt.ddt +class ProgressRestrictionTestBase(ModuleStoreTestCase, LoginEnrollmentTestCase): + """Test methods related to progress restriction.""" + + def setUp(self): + super(ProgressRestrictionTestBase, self).setUp() + + # self.course = CourseFactory.create(start=timezone.now() - timedelta(days=10)) + self.course = CourseFactory.create() + self.setup_user() + self.enroll(self.course) + self.factory = RequestFactory() + + self.vertical_number = dict() + self.chapters = [] + self.chapter_ids = [] + self.chapter_location_names = [] + self.sections = [] + self.section_location_names = [] + self.verticals = [] + + self.setup_course() + + def set_course_optional_setting(self): + self.course_optional_configuration = CourseOptionalConfiguration( + id=1, + change_date="2017-11-06 16:02:13", + enabled=True, + key='progress-restriction-settings', + course_key=self.course.id, + changed_by_id=self.user.id + ) + self.course_optional_configuration.save() + + def setup_course(self): + vertical_metadata_with_restriction = { + 'graded': True, + 'format': 'Homework', + 'due': '2030-12-25T00:00', + 'progress_restriction': { + 'type': 'Correct Answer Rate', + 'passing_mark': 50, + } + } + vertical_metadata_without_restriction_01 = { + 'graded': True, + 'format': 'Homework', + 'due': '2030-12-25T00:00', + 'progress_restriction': { + 'type': 'No Restriction', + } + } + vertical_metadata_without_restriction_02 = { + 'graded': True, + 'format': 'Homework', + 'due': '2030-12-25T00:00', + } + + with modulestore().default_store(ModuleStoreEnum.Type.mongo): + for ch_idx in range(3): + self.chapters.append(ItemFactory(parent=self.course, category='chapter', graded=True)) + self.chapter_ids.append(ch_idx) + self.chapter_location_names.append(self.chapters[ch_idx].location.name) + + self.sections.append([]) + for sc_idx in range(2): + self.sections[ch_idx].append(ItemFactory(parent=self.chapters[ch_idx], category='sequential')) + self.section_location_names.append(self.sections[ch_idx][sc_idx].location.name) + + self.verticals.append( + ItemFactory(parent=self.sections[ch_idx][sc_idx], + category='vertical', + metadata=vertical_metadata_with_restriction) + ) + self.verticals.append( + ItemFactory(parent=self.sections[ch_idx][sc_idx], + category='vertical', + metadata=vertical_metadata_without_restriction_01) + ) + self.verticals.append( + ItemFactory(parent=self.sections[ch_idx][sc_idx], + category='vertical', + metadata=vertical_metadata_with_restriction) + ) + self.verticals.append( + ItemFactory(parent=self.sections[ch_idx][sc_idx], + category='vertical', + metadata=vertical_metadata_without_restriction_02) + ) + + self.prob_xml = OptionResponseXMLFactory().build_xml( + question_text='The correct answer is Correct', + num_inputs=1, + weight=1, + options=['Correct', 'Incorrect'], + correct_option='Correct' + ) + + # chapter 1 / section 1 / vertical 3 + self.problem1 = ItemFactory.create( + parent_location=self.verticals[2].location, + category='problem', + data=self.prob_xml, + display_name='p1' + ) + + self.problem2 = ItemFactory.create( + parent_location=self.verticals[2].location, + category='problem', + data=self.prob_xml, + display_name='p2' + ) + + # chapter 1 / section 2 / vertical 1 + self.problem3 = ItemFactory.create( + parent_location=self.verticals[4].location, + category='problem', + data=self.prob_xml, + display_name='p3' + ) + + # chapter 2 / section 1 / vertical 1 + self.problem4 = ItemFactory.create( + parent_location=self.verticals[8].location, + category='problem', + data=self.prob_xml, + display_name='p4' + ) + + self.course = modulestore().get_course(self.course.id, depth=5) + + def submit_problem(self, problem, responses): + answer_key_prefix = 'input_{}_'.format(problem.location.html_id()) + + # format the response dictionary to be sent in the post request by adding the above prefix to each key + response_dict = {(answer_key_prefix + k): v for k, v in responses.items()} + + resp = self.client.post( + reverse( + 'xblock_handler', + kwargs={ + 'course_id': self.course.id.to_deprecated_string(), + 'usage_id': quote_slashes(problem.location.to_deprecated_string()), + 'handler': 'xmodule_handler', + 'suffix': 'problem_check', + } + ), + response_dict + ) + + self.assertEqual(resp.status_code, 200) + + return resp.content + + def get_progress_restriction_obj(self): + fake_request = self.factory.get( + reverse('courseware', kwargs={'course_id': unicode(self.course.id)}) + ) + + with modulestore().bulk_operations(self.course.id): + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, self.user, self.course, depth=5 + ) + course_module = get_module_for_descriptor( + self.user, + fake_request, + self.course, + field_data_cache, + self.course.id, + course=self.course + ) + progress_restriction = ProgressRestriction(self.course.id, self.user, course_module) + + return progress_restriction + + +@attr('shard_1') +@ddt.ddt +class ProgressRestrictionTest(ProgressRestrictionTestBase): + """Test methods related to progress restriction.""" + + def setUp(self): + super(ProgressRestrictionTest, self).setUp() + + def test_get_restricted_list_in_section_before_answer_correctly(self): + self.set_course_optional_setting() + + self.submit_problem(self.problem1, {'2_1': 'Correct'}) + + progress_restriction = self.get_progress_restriction_obj() + + self.assertEqual( + progress_restriction.get_restricted_list_in_section(self.section_location_names[1]), + [1, 2, 3] + ) + + def test_get_restricted_list_in_section_after_answer_correctly(self): + self.set_course_optional_setting() + + self.submit_problem(self.problem1, {'2_1': 'Correct'}) + self.submit_problem(self.problem3, {'2_1': 'Correct'}) + + progress_restriction = self.get_progress_restriction_obj() + + self.assertEqual( + progress_restriction.get_restricted_list_in_section(self.section_location_names[1]), + [] + ) + + def test_get_restricted_list_in_same_section_before_answer_correctly(self): + self.set_course_optional_setting() + + self.submit_problem(self.problem1, {'2_1': 'Correct'}) + + progress_restriction = self.get_progress_restriction_obj() + + self.assertEqual( + progress_restriction.get_restricted_list_in_same_section(unicode(self.verticals[4].location.name)), + [1, 2, 3] + ) + + def test_get_restricted_list_in_same_section_after_answer_correctly(self): + self.set_course_optional_setting() + + self.submit_problem(self.problem1, {'2_1': 'Correct'}) + self.submit_problem(self.problem3, {'2_1': 'Correct'}) + + progress_restriction = self.get_progress_restriction_obj() + + self.assertEqual( + progress_restriction.get_restricted_list_in_same_section(unicode(self.verticals[4].location.name)), + [] + ) + + def test_get_restricted_chapters_before_answer(self): + self.set_course_optional_setting() + + progress_restriction = self.get_progress_restriction_obj() + + self.assertEqual(progress_restriction.get_restricted_chapters(), [self.chapter_ids[1], + self.chapter_ids[2]]) + + def test_get_restricted_chapters_after_answer(self): + self.set_course_optional_setting() + + self.submit_problem(self.problem1, {'2_1': 'Correct'}) + self.submit_problem(self.problem3, {'2_1': 'Correct'}) + + progress_restriction = self.get_progress_restriction_obj() + + self.assertEqual(progress_restriction.get_restricted_chapters(), [self.chapter_ids[2]]) + + def test_get_restricted_sections_before_answer(self): + self.set_course_optional_setting() + + progress_restriction = self.get_progress_restriction_obj() + + restricted_sections = progress_restriction.get_restricted_sections() + + self.assertEqual( + restricted_sections[self.chapter_ids[0]], + [2] + ) + self.assertEqual( + restricted_sections[self.chapter_ids[1]], + [1, 2] + ) + self.assertEqual( + restricted_sections[self.chapter_ids[2]], + [1, 2] + ) + + def test_get_restricted_sections_after_answer(self): + self.set_course_optional_setting() + + self.submit_problem(self.problem1, {'2_1': 'Correct'}) + self.submit_problem(self.problem3, {'2_1': 'Correct'}) + self.submit_problem(self.problem4, {'2_1': 'Correct'}) + + progress_restriction = self.get_progress_restriction_obj() + restricted_sections = progress_restriction.get_restricted_sections() + + self.assertEqual( + restricted_sections[self.chapter_ids[0]], + [] + ) + self.assertEqual( + restricted_sections[self.chapter_ids[1]], + [] + ) + self.assertEqual( + restricted_sections[self.chapter_ids[2]], + [] + ) + + @ddt.data( + (0, False), + (1, True), + (2, True), + ) + @ddt.unpack + def test_is_restricted_chapter(self, chapter_idx, is_restricted): + self.set_course_optional_setting() + + chapter = self.chapter_location_names[chapter_idx] + progress_restriction = self.get_progress_restriction_obj() + self.assertEqual(progress_restriction.is_restricted_chapter(chapter), is_restricted) + + @ddt.data( + (0, False), + (1, True), + (2, True), + (3, True), + (4, True), + (5, True), + ) + @ddt.unpack + def test_is_restricted_section(self, section_idx, is_restricted): + self.set_course_optional_setting() + + section = self.section_location_names[section_idx] + progress_restriction = self.get_progress_restriction_obj() + self.assertEqual(progress_restriction.is_restricted_section(section), is_restricted) diff --git a/lms/djangoapps/courseware/tests/test_ga_tabs.py b/lms/djangoapps/courseware/tests/test_ga_tabs.py index 078264f392a8..cb7b958b8c4c 100644 --- a/lms/djangoapps/courseware/tests/test_ga_tabs.py +++ b/lms/djangoapps/courseware/tests/test_ga_tabs.py @@ -9,6 +9,7 @@ from courseware.tabs import get_course_tab_list from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase from courseware.tests.test_tabs import TabTestCase +from student.roles import GaCourseScorerRole, GaGlobalCourseCreatorRole from student.tests.factories import CourseAccessRoleFactory @@ -76,3 +77,74 @@ def test_self_paced_course_closed(self, is_blocked, is_staff, is_old_course_view if is_blocked and not is_staff: self.assertEqual(mock_is_course_closed.call_count, 4) + + +@ddt.ddt +class CourseTerminatedCheckTabTestWithGaGlobalCourseCreator(LoginEnrollmentTestCase, TabTestCase): + + def _create_request(self): + self.setup_user() + GaGlobalCourseCreatorRole().add_users(self.user) + self.enroll(self.course) + return get_request_for_user(self.user) + + def _assert_tabs(self, tabs): + tab_types = [tab.type for tab in tabs] + self.assertItemsEqual(tab_types, ['courseware', 'course_info', 'wiki', 'progress']) + + def test_course_opened(self): + request = self._create_request() + tab_list = get_course_tab_list(request, self.course) + self._assert_tabs(tab_list) + + def test_course_terminated(self): + self.course.terminate_start = timezone.now() - timedelta(days=1) + self.update_course(self.course, self.user.id) + + request = self._create_request() + tab_list = get_course_tab_list(request, self.course) + self._assert_tabs(tab_list) + + @patch('openedx.core.djangoapps.ga_self_paced.api.is_course_closed', return_value=True) + def test_self_paced_course_closed(self, mock_is_course_closed): + self.course.self_paced = True + self.update_course(self.course, self.user.id) + + request = self._create_request() + tab_list = get_course_tab_list(request, self.course) + self._assert_tabs(tab_list) + + +class CourseTerminatedCheckTabTestWithGaCourseScorer(LoginEnrollmentTestCase, TabTestCase): + + def _create_request(self): + self.setup_user() + GaCourseScorerRole(self.course.id).add_users(self.user) + self.enroll(self.course) + return get_request_for_user(self.user) + + def _assert_tabs(self, tabs): + tab_types = [tab.type for tab in tabs] + self.assertItemsEqual(tab_types, ['courseware', 'course_info', 'wiki', 'progress', 'instructor']) + + def test_course_opened(self): + request = self._create_request() + tab_list = get_course_tab_list(request, self.course) + self._assert_tabs(tab_list) + + def test_course_terminated(self): + self.course.terminate_start = timezone.now() - timedelta(days=1) + self.update_course(self.course, self.user.id) + + request = self._create_request() + tab_list = get_course_tab_list(request, self.course) + self.assertItemsEqual(tab_list, []) + + @patch('openedx.core.djangoapps.ga_self_paced.api.is_course_closed', return_value=True) + def test_self_paced_course_closed(self, mock_is_course_closed): + self.course.self_paced = True + self.update_course(self.course, self.user.id) + + request = self._create_request() + tab_list = get_course_tab_list(request, self.course) + self._assert_tabs(tab_list) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index e35f14cb5454..cfce4d181abf 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -35,9 +35,10 @@ from certificates.tests.factories import GeneratedCertificateFactory from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory +from courseware.ga_progress_restriction import ProgressRestriction from courseware.model_data import set_score from courseware.testutils import RenderXBlockTestMixin -from courseware.tests.factories import StudentModuleFactory +from courseware.tests.factories import GaCourseScorerFactory, GaGlobalCourseCreatorFactory, StudentModuleFactory from courseware.user_state_client import DjangoXBlockUserStateClient from edxmako.tests import mako_middleware_process_request from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration @@ -450,6 +451,51 @@ def test_submission_history_contents(self): self.assertIn("Score: 3.0 / 3.0", response_content) self.assertIn('#4', response_content) + def test_submission_history_contents_with_ga_course_scorer(self): + # log into a GaCourseScorer + ga_course_scorer = GaCourseScorerFactory(course_key=self.course.id) + + self.client.login(username=ga_course_scorer.username, password='test') + + usage_key = self.course_key.make_usage_key('problem', 'test-history') + state_client = DjangoXBlockUserStateClient(ga_course_scorer) + + # store state via the UserStateClient + state_client.set( + username=ga_course_scorer.username, + block_key=usage_key, + state={'field_a': 'x', 'field_b': 'y'} + ) + + set_score(ga_course_scorer.id, usage_key, 0, 3) + + state_client.set( + username=ga_course_scorer.username, + block_key=usage_key, + state={'field_a': 'a', 'field_b': 'b'} + ) + set_score(ga_course_scorer.id, usage_key, 3, 3) + + url = reverse('submission_history', kwargs={ + 'course_id': unicode(self.course_key), + 'student_username': ga_course_scorer.username, + 'location': unicode(usage_key), + }) + response = self.client.get(url) + response_content = HTMLParser().unescape(response.content.decode('utf-8')) + + # We have update the state 4 times: twice to change content, and twice + # to set the scores. We'll check that the identifying content from each is + # displayed (but not the order), and also the indexes assigned in the output + # #1 - #4 + + self.assertIn('#1', response_content) + self.assertIn(json.dumps({'field_a': 'a', 'field_b': 'b'}, sort_keys=True, indent=2), response_content) + self.assertIn("Score: 0.0 / 3.0", response_content) + self.assertIn(json.dumps({'field_a': 'x', 'field_b': 'y'}, sort_keys=True, indent=2), response_content) + self.assertIn("Score: 3.0 / 3.0", response_content) + self.assertIn('#4', response_content) + @ddt.data(('America/New_York', -5), # UTC - 5 ('Asia/Pyongyang', 9), # UTC + 9 ('Europe/London', 0), # UTC @@ -741,9 +787,11 @@ class TestAccordionDueDate(BaseDueDateTests): def get_text(self, course): """ Returns the HTML for the accordion """ + progress_restriction = ProgressRestriction(course.id, self.user, None) + return views.render_accordion( self.request.user, self.request, course, - unicode(course.get_children()[0].scope_ids.usage_id), None, None + unicode(course.get_children()[0].scope_ids.usage_id), None, None, progress_restriction ) @@ -1351,3 +1399,24 @@ def setUp(self): def course_options(self): return {'self_paced': True} + + +class TestVisibleStudioUrl(ModuleStoreTestCase): + """ + Tests for the view studio url + """ + def setUp(self): + super(TestVisibleStudioUrl, self).setUp() + self.course = CourseFactory.create() + + def test_get_studio_url(self): + # Global staff + self.assertIsNotNone(views._get_studio_url(self.user, self.course, 'course')) + + # GaGlobalCourseCreatorFactory + ga_global_course_creator = GaGlobalCourseCreatorFactory() + self.assertIsNone(views._get_studio_url(ga_global_course_creator, self.course, 'course')) + + # GaCourseScorer + ga_course_scorer = GaCourseScorerFactory(course_key=self.course.id) + self.assertIsNone(views._get_studio_url(ga_course_scorer, self.course, 'course')) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 3f119785f0c4..bf8333ddf1c6 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -48,6 +48,7 @@ sort_by_start_date, UserNotEnrolled ) +from courseware.ga_progress_restriction import ProgressRestriction from courseware.masquerade import setup_masquerade from openedx.core.djangoapps.credit.api import ( get_credit_requirement_status, @@ -55,7 +56,7 @@ is_credit_course ) from openedx.core.djangoapps.ga_optional.api import is_available -from openedx.core.djangoapps.ga_optional.models import DISCCUSION_IMAGE_UPLOAD_KEY +from openedx.core.djangoapps.ga_optional.models import DISCCUSION_IMAGE_UPLOAD_KEY, PROGRESS_RESTRICTION_OPTION_KEY from courseware.models import StudentModuleHistory from courseware.model_data import FieldDataCache, ScoresClient from .module_render import toc_for_course, get_module_for_descriptor, get_module, get_module_by_usage_id @@ -71,6 +72,7 @@ from pdfgen import api as pdfgen_api from student.models import UserTestGroup, CourseEnrollment +from student.roles import GaCourseScorerRole, GaGlobalCourseCreatorRole from student.views import is_course_blocked from util.cache import cache, cache_if_anonymous from util.date_utils import strftime_localized @@ -153,7 +155,7 @@ def courses(request): ) -def render_accordion(user, request, course, chapter, section, field_data_cache, is_modal=False): +def render_accordion(user, request, course, chapter, section, field_data_cache, progress_restriction, is_modal=False): """ Draws navigation bar. Takes current position in accordion as parameter. @@ -167,6 +169,18 @@ def render_accordion(user, request, course, chapter, section, field_data_cache, # grab the table of contents toc = toc_for_course(user, request, course, chapter, section, field_data_cache) + # to gray out chapters and sections restricted by progress restriction + if progress_restriction: + for ch_toc in toc: + ch_toc['restricted'] = progress_restriction.is_restricted_chapter(ch_toc['url_name']) + for sc_toc in ch_toc['sections']: + sc_toc['restricted'] = progress_restriction.is_restricted_section(sc_toc['url_name']) + else: + for ch_toc in toc: + ch_toc['restricted'] = False + for sc_toc in ch_toc['sections']: + sc_toc['restricted'] = False + context = dict([ ('toc', toc), ('course_id', course.id.to_deprecated_string()), @@ -415,12 +429,17 @@ def _index_bulk_op(request, course_key, chapter, section, position): u' far, should have gotten a course module for this user') return redirect(reverse('about_course', args=[course_key.to_deprecated_string()])) - studio_url = get_studio_url(course, 'course') + studio_url = _get_studio_url(user, course, 'course') + + progress_restriction = ProgressRestriction(course.id, user, course_module) \ + if is_available(PROGRESS_RESTRICTION_OPTION_KEY, course_key) and not staff_access else None context = { 'csrf': csrf(request)['csrf_token'], - 'accordion': render_accordion(user, request, course, chapter, section, field_data_cache), - 'accordion_modal': render_accordion(user, request, course, chapter, section, field_data_cache, is_modal=True), + 'accordion': render_accordion(user, request, course, chapter, section, field_data_cache, + progress_restriction), + 'accordion_modal': render_accordion(user, request, course, chapter, section, field_data_cache, + progress_restriction, is_modal=True), 'COURSE_TITLE': course.display_name_with_default, 'course': course, 'init': '', @@ -430,6 +449,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): 'masquerade': masquerade, 'xqa_server': settings.FEATURES.get('XQA_SERVER', "http://your_xqa_server.com"), 'is_image_upload': is_available(DISCCUSION_IMAGE_UPLOAD_KEY, course_key), + 'restricted_list': [], } now = datetime.now(UTC()) @@ -528,6 +548,10 @@ def _index_bulk_op(request, course_key, chapter, section, position): # they don't have access to. raise Http404 + # Add list of subsections(sequence nubmers) restricted by progress restriction + if progress_restriction is not None: + context['restricted_list'] = progress_restriction.get_restricted_list_in_section(section) + # Save where we are in the chapter. save_child_position(chapter_module, section) section_render_context = {'activate_block_id': request.GET.get('activate_block_id')} @@ -535,7 +559,7 @@ def _index_bulk_op(request, course_key, chapter, section, position): context['section_title'] = section_descriptor.display_name_with_default else: # section is none, so display a message - studio_url = get_studio_url(course, 'course') + studio_url = _get_studio_url(user, course, 'course') prev_section = get_current_child(chapter_module) if prev_section is None: # Something went wrong -- perhaps this chapter has no sections visible to the user. @@ -696,7 +720,7 @@ def course_info(request, course_id): if request.user.is_authenticated() and survey.utils.must_answer_survey(course, user): return redirect(reverse('course_survey', args=[unicode(course.id)])) - studio_url = get_studio_url(course, 'course_info') + studio_url = _get_studio_url(user, course, 'course_info') # link to where the student should go to enroll in the course: # about page if there is not marketing site, SITE_NAME if there is @@ -839,7 +863,7 @@ def course_about(request, course_id): registered = registered_for_course(course, request.user) staff_access = bool(has_access(request.user, 'staff', course)) - studio_url = get_studio_url(course, 'settings/details') + studio_url = _get_studio_url(request.user, course, 'settings/details') if has_access(request.user, 'load', course): course_target = reverse('info', args=[course.id.to_deprecated_string()]) @@ -981,7 +1005,7 @@ def _progress(request, course_key, student_id): grade_summary = grades.grade( student, request, course, field_data_cache=field_data_cache, scores_client=scores_client ) - studio_url = get_studio_url(course, 'settings/grading') + studio_url = _get_studio_url(request.user, course, 'settings/grading') if courseware_summary is None: #This means the student didn't have access to the course (which the instructor requested) @@ -1641,3 +1665,10 @@ def financial_assistance_form(request): } ], }) + + +def _get_studio_url(user, course, page): + # Note: GaGlobalCourseCreator and GaCourseScorer cannot see the studio link (#2150) + if GaGlobalCourseCreatorRole().has_user(user) or GaCourseScorerRole(course.id).has_user(user): + return None + return get_studio_url(course, page) diff --git a/lms/djangoapps/ga_advanced_course/models.py b/lms/djangoapps/ga_advanced_course/models.py index c279147af07d..6e3e5390d5ed 100644 --- a/lms/djangoapps/ga_advanced_course/models.py +++ b/lms/djangoapps/ga_advanced_course/models.py @@ -42,11 +42,11 @@ class AdvancedCourse(models.Model): display_name = models.CharField(max_length=255, verbose_name=_("Display Name")) - start_date = models.DateField(verbose_name=_("Start Date")) + start_date = models.DateField(verbose_name=_("Start Date (JST)")) - start_time = models.TimeField(verbose_name=_("Start Time")) + start_time = models.TimeField(verbose_name=_("Start Time (JST)")) - end_time = models.TimeField(verbose_name=_("End Time")) + end_time = models.TimeField(verbose_name=_("End Time (JST)")) capacity = models.IntegerField(default=0, verbose_name=_("Capacity")) diff --git a/lms/djangoapps/ga_operation/management/commands/count_initial_registration_course.py b/lms/djangoapps/ga_operation/management/commands/count_initial_registration_course.py new file mode 100644 index 000000000000..ff9021d0029f --- /dev/null +++ b/lms/djangoapps/ga_operation/management/commands/count_initial_registration_course.py @@ -0,0 +1,53 @@ +import logging +import pytz +import traceback +from datetime import datetime + +from django.conf import settings +from django.core.mail import send_mail +from django.core.management.base import BaseCommand +from django.db import connection + +log = logging.getLogger(__name__) +query_text = """ +SELECT a.course_id, COUNT(a.course_id) +FROM student_courseenrollment a INNER JOIN ( + SELECT user_id, MIN(created) AS created + FROM student_courseenrollment + WHERE course_id NOT IN (SELECT course_id FROM course_global_courseglobalsetting WHERE global_enabled = 1) AND course_id LIKE '%gacco%' + GROUP BY user_id +) b +ON a.user_id = b.user_id AND a.created = b.created +GROUP BY a.course_id; +""" + + +class Command(BaseCommand): + """ + This command allows you to aggregate user's initial registration course. + + Usage: python manage.py lms --settings=aws count_initial_registration_course + """ + help = 'Usage: count_initial_registration_course' + + def handle(self, *args, **options): + body = None + + try: + with connection.cursor() as cursor: + cursor.execute(query_text) + body = "\n".join(['"{0}",{1}'.format(*c) for c in cursor.fetchall()]) + log.info(body) + except Exception as e: + log.exception('Caught the exception: ' + type(e).__name__) + body = traceback.format_exc() + finally: + send_mail( + 'Initial registration course daily report ({0:%Y/%m/%d})'.format( + pytz.timezone(settings.TIME_ZONE).localize(datetime.now()), + ), + body, + settings.GA_OPERATION_EMAIL_SENDER_REGISTRATION_COURSE_DAILY_REPORT, + settings.GA_OPERATION_CALLBACK_EMAIL_REGISTRATION_COURSE_DAILY_REPORT, + fail_silently=False + ) diff --git a/lms/djangoapps/ga_operation/management/commands/tests/test_count_initial_registration_course.py b/lms/djangoapps/ga_operation/management/commands/tests/test_count_initial_registration_course.py new file mode 100644 index 000000000000..d22a365f4bd3 --- /dev/null +++ b/lms/djangoapps/ga_operation/management/commands/tests/test_count_initial_registration_course.py @@ -0,0 +1,81 @@ +import ddt +from datetime import datetime +from mock import patch + +from django.conf import settings +from django.core.management import call_command +from django.test.utils import override_settings + +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.course_global.tests.factories import CourseGlobalSettingFactory +from student.tests.factories import CourseEnrollmentFactory, UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@ddt.ddt +@override_settings( + TIME_ZONE='Asia/Tokyo', + GA_OPERATION_EMAIL_SENDER_REGISTRATION_COURSE_DAILY_REPORT='sender@example.com', + GA_OPERATION_CALLBACK_EMAIL_REGISTRATION_COURSE_DAILY_REPORT=['receiver@example.com'], +) +class InitialRegistrationCourseCountTest(ModuleStoreTestCase): + + def setUp(self): + super(InitialRegistrationCourseCountTest, self).setUp() + + @staticmethod + def _setup_course_enrollment(course_id_dict, user_count): + course_list = [] + for course_id, global_enabled in course_id_dict.iteritems(): + course_key = CourseKey.from_string(course_id) + CourseGlobalSettingFactory.create(course_id=course_key, global_enabled=global_enabled) + course_list.append(CourseFactory.create(org=course_key.org, course=course_key.course, run=course_key.run)) + for _ in range(user_count): + for course in course_list: + CourseEnrollmentFactory.create(user=UserFactory.create(), course_id=course.id) + + @patch('ga_operation.management.commands.count_initial_registration_course.datetime') + @patch('ga_operation.management.commands.count_initial_registration_course.traceback.format_exc', return_value='dummy_traceback') + @patch('ga_operation.management.commands.count_initial_registration_course.send_mail') + @ddt.data( + ({'gacco/ht001/2015_00': True}, 3, 0), + ({'gacco/ht001/2015_00': True, 'gacco/course1/run': False}, 3, 3), + ({'gacco/ht001/2015_00': True, 'other/course/run': False, 'gacco/course1/run': False}, 3, 3), + ) + @ddt.unpack + def test_handle(self, course_id_dict, user_count, expect_count, mock_send_mail, mock_traceback, mock_datetime): + expect_datetime = datetime(2020, 7, 24) + mock_datetime.now.return_value = expect_datetime + self._setup_course_enrollment(course_id_dict, user_count) + + call_command('count_initial_registration_course') + + mock_traceback.assert_not_called_once() + mock_send_mail.assert_called_once_with( + 'Initial registration course daily report ({0:%Y/%m/%d})'.format(expect_datetime), + '"gacco/course1/run",{}'.format(expect_count) if expect_count else '', + settings.GA_OPERATION_EMAIL_SENDER_REGISTRATION_COURSE_DAILY_REPORT, + settings.GA_OPERATION_CALLBACK_EMAIL_REGISTRATION_COURSE_DAILY_REPORT, + fail_silently=False, + ) + + @patch('ga_operation.management.commands.count_initial_registration_course.datetime') + @patch('ga_operation.management.commands.count_initial_registration_course.traceback.format_exc', return_value='dummy_traceback') + @patch('ga_operation.management.commands.count_initial_registration_course.send_mail') + @patch('ga_operation.management.commands.count_initial_registration_course.connection') + def test_handle_caught_exception(self, mock_connection, mock_send_mail, mock_traceback, mock_datetime): + expect_datetime = datetime(2020, 7, 24) + mock_datetime.now.return_value = expect_datetime + mock_connection.cursor().__enter__().execute.side_effect = Exception() + + call_command('count_initial_registration_course') + + mock_traceback.assert_called_once() + mock_send_mail.assert_called_once_with( + 'Initial registration course daily report ({0:%Y/%m/%d})'.format(expect_datetime), + 'dummy_traceback', + settings.GA_OPERATION_EMAIL_SENDER_REGISTRATION_COURSE_DAILY_REPORT, + settings.GA_OPERATION_CALLBACK_EMAIL_REGISTRATION_COURSE_DAILY_REPORT, + fail_silently=False, + ) diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index a62a5a6dd5ef..047edc0f17cb 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -17,6 +17,7 @@ CourseInstructorRole, CourseCcxCoachRole, CourseStaffRole, + GaCourseScorerRole, ) from instructor.enrollment import ( @@ -31,6 +32,8 @@ 'instructor': CourseInstructorRole, 'staff': CourseStaffRole, 'ccx_coach': CourseCcxCoachRole, + # Note: GaCourseScorer is registered by instructor from membership of instructor tab (#2150) + 'ga_course_scorer': GaCourseScorerRole, } diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index cec1935634a5..8d72bd683511 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -35,7 +35,8 @@ from course_modes.models import CourseMode from courseware.models import StudentModule from courseware.tests.factories import ( - BetaTesterFactory, GlobalStaffFactory, InstructorFactory, StaffFactory, UserProfileFactory + BetaTesterFactory, GlobalStaffFactory, InstructorFactory, StaffFactory, UserProfileFactory, + GaCourseScorerFactory ) from courseware.tests.helpers import LoginEnrollmentTestCase from django_comment_common.models import FORUM_ROLE_COMMUNITY_TA @@ -74,6 +75,7 @@ from certificates.models import CertificateStatuses from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings +from openedx.core.lib.ga_datetime_utils import format_for_csv from openedx.core.lib.xblock_utils import grade_histogram from .test_tools import msk_from_problem_urlname @@ -1943,6 +1945,7 @@ def setUp(self): self.other_instructor = InstructorFactory(course_key=self.course.id) self.other_staff = StaffFactory(course_key=self.course.id) self.other_user = UserFactory() + self.other_ga_course_scorer = GaCourseScorerFactory(course_key=self.course.id) def test_modify_access_noparams(self): """ Test missing all query parameters. """ @@ -1979,6 +1982,15 @@ def test_modify_access_allow(self): }) self.assertEqual(response.status_code, 200) + def test_modify_access_allow_ga_course_scorer(self): + url = reverse('modify_access', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(url, { + 'unique_student_identifier': self.other_user.email, + 'rolename': 'ga_course_scorer', + 'action': 'allow', + }) + self.assertEqual(response.status_code, 200) + def test_modify_access_allow_with_uname(self): url = reverse('modify_access', kwargs={'course_id': self.course.id.to_deprecated_string()}) response = self.client.post(url, { @@ -1997,6 +2009,15 @@ def test_modify_access_revoke(self): }) self.assertEqual(response.status_code, 200) + def test_modify_access_revoke_ga_course_scorer(self): + url = reverse('modify_access', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(url, { + 'unique_student_identifier': self.other_ga_course_scorer.email, + 'rolename': 'ga_course_scorer', + 'action': 'revoke', + }) + self.assertEqual(response.status_code, 200) + def test_modify_access_revoke_with_username(self): url = reverse('modify_access', kwargs={'course_id': self.course.id.to_deprecated_string()}) response = self.client.post(url, { @@ -2105,6 +2126,28 @@ def test_list_course_role_members_staff(self): res_json = json.loads(response.content) self.assertEqual(res_json, expected) + def test_list_course_role_members_ga_course_scorer(self): + url = reverse('list_course_role_members', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(url, { + 'rolename': 'ga_course_scorer', + }) + self.assertEqual(response.status_code, 200) + + # check response content + expected = { + 'course_id': self.course.id.to_deprecated_string(), + 'ga_course_scorer': [ + { + 'username': self.other_ga_course_scorer.username, + 'email': self.other_ga_course_scorer.email, + 'first_name': self.other_ga_course_scorer.first_name, + 'last_name': self.other_ga_course_scorer.last_name, + } + ] + } + res_json = json.loads(response.content) + self.assertEqual(res_json, expected) + def test_list_course_role_members_beta(self): url = reverse('list_course_role_members', kwargs={'course_id': self.course.id.to_deprecated_string()}) response = self.client.post(url, { @@ -3452,6 +3495,14 @@ def test_send_email_as_logged_in_instructor(self): response = self.client.post(url, self.full_test_message) self.assertEqual(response.status_code, 200) + def test_send_email_as_logged_in_ga_course_scorer(self): + self.client.logout() + ga_course_scorer = GaCourseScorerFactory(course_key=self.course.id) + self.client.login(username=ga_course_scorer.username, password='test') + url = reverse('send_email', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(url, self.full_test_message) + self.assertEqual(response.status_code, 200) + def test_send_email_but_not_logged_in(self): self.client.logout() url = reverse('send_email', kwargs={'course_id': self.course.id.to_deprecated_string()}) @@ -3606,6 +3657,28 @@ def test_list_instructor_tasks_running(self, act): self.assertDictEqual(exp_task, act_task) self.assertEqual(actual_tasks, expected_tasks) + @patch.object(instructor_task.api, 'get_running_instructor_tasks') + def test_list_instructor_tasks_running_with_ga_course_scorer(self, act): + """ Test list of all running tasks. """ + self.client.logout() + self.instructor = GaCourseScorerFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password='test') + act.return_value = self.tasks + url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id.to_deprecated_string()}) + mock_factory = MockCompletionInfo() + with patch('instructor.views.instructor_task_helpers.get_task_completion_info') as mock_completion_info: + mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 200) + + # check response + self.assertTrue(act.called) + expected_tasks = [ftask.to_dict() for ftask in self.tasks] + actual_tasks = json.loads(response.content)['tasks'] + for exp_task, act_task in zip(expected_tasks, actual_tasks): + self.assertDictEqual(exp_task, act_task) + self.assertEqual(actual_tasks, expected_tasks) + @patch.object(instructor_task.api, 'get_instructor_task_history') def test_list_background_email_tasks(self, act): """Test list of background email tasks.""" @@ -3625,6 +3698,27 @@ def test_list_background_email_tasks(self, act): self.assertDictEqual(exp_task, act_task) self.assertEqual(actual_tasks, expected_tasks) + @patch.object(instructor_task.api, 'get_instructor_task_history') + def test_list_background_email_tasks_with_ga_course_scorer(self, act): + """Test list of background email tasks.""" + self.instructor = GaCourseScorerFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password='test') + act.return_value = self.tasks + url = reverse('list_background_email_tasks', kwargs={'course_id': self.course.id.to_deprecated_string()}) + mock_factory = MockCompletionInfo() + with patch('instructor.views.instructor_task_helpers.get_task_completion_info') as mock_completion_info: + mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 200) + + # check response + self.assertTrue(act.called) + expected_tasks = [ftask.to_dict() for ftask in self.tasks] + actual_tasks = json.loads(response.content)['tasks'] + for exp_task, act_task in zip(expected_tasks, actual_tasks): + self.assertDictEqual(exp_task, act_task) + self.assertEqual(actual_tasks, expected_tasks) + @patch.object(instructor_task.api, 'get_instructor_task_history') def test_list_instructor_tasks_problem(self, act): """ Test list task history for problem. """ @@ -3804,6 +3898,18 @@ def test_list_email_with_no_successes(self, task_history_request): self.assertDictEqual(expected_info, returned_info) +@attr('shard_1') +class TestInstructorEmailContentListWithGaCourseScorer(TestInstructorEmailContentList): + """ + Test the instructor email content history endpoint. + """ + def setUp(self): + super(TestInstructorEmailContentListWithGaCourseScorer, self).setUp() + self.client.logout() + self.instructor = GaCourseScorerFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password='test') + + @attr('shard_1') class TestInstructorAPIHelpers(TestCase): """ Test helpers for instructor.api """ @@ -4774,6 +4880,7 @@ def test_success_carriage_return_line_feed(self, mock_store_upload, mock_cohort_ 'username,email,cohort\r\nfoo_username,bar_email,baz_cohort', mock_store_upload, mock_cohort_task ) + class InstructorAPISurveyDownloadTestMixin(object): """ Test instructor survey mix-in. @@ -4876,17 +4983,17 @@ def test_get_survey(self): self.assertEqual( rows[1], '"11111111111111111111111111111111","survey #1","%s","%s","","","1","1,2","submission #1","N/A"' - % (submission1.created, submission1.user.username) + % (format_for_csv(submission1.created), submission1.user.username) ) self.assertEqual( rows[2], '"11111111111111111111111111111111","survey #1","%s","%s","1","1","1","2","submission #2","N/A"' - % (submission2.created, submission2.user.username) + % (format_for_csv(submission2.created), submission2.user.username) ) self.assertEqual( rows[3], '"22222222222222222222222222222222","survey #2","%s","%s","","1","","","","extra"' - % (submission3.created, submission3.user.username) + % (format_for_csv(submission3.created), submission3.user.username) ) def test_get_survey_when_data_is_empty(self): @@ -4910,12 +5017,12 @@ def test_get_survey_when_data_is_broken(self): self.assertEqual( rows[1], '"11111111111111111111111111111111","survey #1","%s","%s","","","1","1,2","submission #1"' - % (submission1.created, submission1.user.username) + % (format_for_csv(submission1.created), submission1.user.username) ) self.assertEqual( rows[2], '"22222222222222222222222222222222","survey #5","%s","%s","","","N/A","N/A","N/A"' - % (submission5.created, submission5.user.username) + % (format_for_csv(submission5.created), submission5.user.username) ) diff --git a/lms/djangoapps/instructor/tests/test_ga_access.py b/lms/djangoapps/instructor/tests/test_ga_access.py new file mode 100644 index 000000000000..6948cd3f7df5 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_ga_access.py @@ -0,0 +1,84 @@ +""" +Test instructor.access +""" + +from nose.plugins.attrib import attr +from instructor.access import ( + allow_access, + list_with_level, + revoke_access, + update_forum_role +) +from student.roles import GaCourseScorerRole +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + + +@attr('shard_1') +class TestGaCourseScorerAccessList(SharedModuleStoreTestCase): + """ Test access listings. """ + @classmethod + def setUpClass(cls): + super(TestGaCourseScorerAccessList, cls).setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super(TestGaCourseScorerAccessList, self).setUp() + self.ga_course_scorer = [UserFactory.create() for _ in xrange(4)] + for user in self.ga_course_scorer: + allow_access(self.course, user, 'ga_course_scorer') + + def test_list(self): + ga_course_scorer = list_with_level(self.course, 'ga_course_scorer') + self.assertEqual(set(ga_course_scorer), set(self.ga_course_scorer)) + + +@attr('shard_1') +class TestGaCourseScorerAccessAllow(SharedModuleStoreTestCase): + """ Test access allow. """ + @classmethod + def setUpClass(cls): + super(TestGaCourseScorerAccessAllow, cls).setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super(TestGaCourseScorerAccessAllow, self).setUp() + + self.course = CourseFactory.create() + + def test_allow(self): + user = UserFactory() + allow_access(self.course, user, 'ga_course_scorer') + self.assertTrue(GaCourseScorerRole(self.course.id).has_user(user)) + + def test_allow_twice(self): + user = UserFactory() + allow_access(self.course, user, 'ga_course_scorer') + allow_access(self.course, user, 'ga_course_scorer') + self.assertTrue(GaCourseScorerRole(self.course.id).has_user(user)) + + +@attr('shard_1') +class TestGaCourseScorerAccessRevoke(SharedModuleStoreTestCase): + """ Test access revoke. """ + @classmethod + def setUpClass(cls): + super(TestGaCourseScorerAccessRevoke, cls).setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super(TestGaCourseScorerAccessRevoke, self).setUp() + self.ga_course_scorer = [UserFactory.create() for _ in xrange(4)] + for user in self.ga_course_scorer: + allow_access(self.course, user, 'ga_course_scorer') + + def test_revoke(self): + user = self.ga_course_scorer[0] + revoke_access(self.course, user, 'ga_course_scorer') + self.assertFalse(GaCourseScorerRole(self.course.id).has_user(user)) + + def test_revoke_twice(self): + user = self.ga_course_scorer[0] + revoke_access(self.course, user, 'ga_course_scorer') + self.assertFalse(GaCourseScorerRole(self.course.id).has_user(user)) diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py index bf100fe10bb1..d46a5a41d7a9 100644 --- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py +++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py @@ -12,7 +12,7 @@ from ccx.tests.test_views import setup_students_and_grades from courseware.tabs import get_course_tab_list -from courseware.tests.factories import UserFactory +from courseware.tests.factories import GaCourseScorerFactory, GaGlobalCourseCreatorFactory, UserFactory from courseware.tests.helpers import LoginEnrollmentTestCase from instructor.views.gradebook_api import calculate_page_info @@ -100,6 +100,10 @@ def has_instructor_tab(user, course): self.assertTrue(has_instructor_tab(self.instructor, self.course)) student = UserFactory.create() self.assertFalse(has_instructor_tab(student, self.course)) + ga_global_course_creator = GaGlobalCourseCreatorFactory.create() + self.assertFalse(has_instructor_tab(ga_global_course_creator, self.course)) + ga_course_scorer = GaCourseScorerFactory.create(course_key=self.course.id) + self.assertTrue(has_instructor_tab(ga_course_scorer, self.course)) def test_default_currency_in_the_html_response(self): """ diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 8d3dfdda30eb..b8019c46d1a4 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -117,6 +117,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys import InvalidKeyError from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted +from openedx.core.lib.ga_datetime_utils import format_for_csv log = logging.getLogger(__name__) @@ -812,11 +813,12 @@ def modify_access(request, course_id): return JsonResponse(response_payload) +# Note: GaCourseScorer is registered by instructor from membership of instructor tab (#2150) @require_POST @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('instructor') -@require_post_params(rolename="'instructor', 'staff', or 'beta'") +@require_post_params(rolename="'instructor', 'staff', or 'beta', 'ga_course_scorer'") def list_course_role_members(request, course_id): """ List instructors and staff. @@ -2720,7 +2722,7 @@ def csv_response(filename, header, rows): msg = "Couldn't parse JSON in survey_answer, so treat each item as 'N/A'. course_id={0}, unit_id={1}, username={2}".format( course_id, s.unit_id, s.username) log.warning(msg) - row = [s.unit_id, s.survey_name, s.created, s.username] + row = [s.unit_id, s.survey_name, format_for_csv(s.created), s.username] row.append('1' if s.account_status == UserStanding.ACCOUNT_DISABLED else '') row.append('1' if not s.is_active else '') for key in keys: diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index e410af433d5d..01755d5ab7c4 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -37,7 +37,7 @@ from student.models import CourseEnrollment from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegCodeItem from course_modes.models import CourseMode, CourseModesArchive -from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole +from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole, GaCourseScorerRole, GaGlobalCourseCreatorRole from certificates.models import ( CertificateGenerationConfiguration, CertificateWhitelist, @@ -73,6 +73,11 @@ def is_enabled(cls, course, user=None): """ Returns true if the specified user has staff access. """ + if user and (GaGlobalCourseCreatorRole().has_user(user) and not user.is_staff): + # Note: GaGlobalCourseCreator cannot see the instructor tab (#2150) + # GaGlobalCourseCreatorRole is not a staff at LMS, + # but because courses made by oneself will become course instructor. + return False return bool(user and has_access(user, 'staff', course, course.id)) @@ -191,7 +196,8 @@ def instructor_dashboard_2(request, course_id): context = { 'course': course, - 'studio_url': get_studio_url(course, 'course'), + # Note: GaCourseScorer cannot see the studio link (#2150) + 'studio_url': None if GaCourseScorerRole(course_key).has_user(request.user) else get_studio_url(course, 'course'), 'sections': [s for s in sections if s is not None], 'disable_buttons': disable_buttons, 'analytics_dashboard_message': analytics_dashboard_message, diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py index b57f2fe6ab7d..438e36c58ff8 100644 --- a/lms/djangoapps/student_profile/views.py +++ b/lms/djangoapps/student_profile/views.py @@ -21,7 +21,7 @@ from openedx.core.djangoapps.user_api.accounts.serializers import PROFILE_IMAGE_KEY_PREFIX from openedx.core.djangoapps.user_api.errors import UserNotFound, UserNotAuthorized from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences -from student.models import User, CourseEnrollment +from student.models import User, UserStanding, CourseEnrollment from microsite_configuration import microsite from xmodule.modulestore.django import modulestore @@ -46,10 +46,17 @@ def learner_profile(request, username): GET /account/profile """ try: - return render_to_response( - 'student_profile/learner_profile.html', - learner_profile_context(request, username, request.user.is_staff) - ) + is_disabled = UserStanding.objects.filter( + user__username=username, + account_status=UserStanding.ACCOUNT_DISABLED + ).exists() + if is_disabled: + return render_to_response('disabled_account.html') + else: + return render_to_response( + 'student_profile/learner_profile.html', + learner_profile_context(request, username, request.user.is_staff) + ) except (UserNotAuthorized, UserNotFound, ObjectDoesNotExist): raise Http404 diff --git a/lms/envs/aws.py b/lms/envs/aws.py index a09cc2c9c3a0..f820fd3a8705 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -836,6 +836,8 @@ GA_OPERATION_TARGET_BUCKETS_OF_AGGREGATE_UPLOAD_S3_OBJECTS = ENV_TOKENS.get('GA_OPERATION_TARGET_BUCKETS_OF_AGGREGATE_UPLOAD_S3_OBJECTS', None) GA_OPERATION_EMAIL_SENDER_FSECURE_REPORT = ENV_TOKENS.get('GA_OPERATION_EMAIL_SENDER_FSECURE_REPORT', None) GA_OPERATION_CALLBACK_EMAIL_SERVICE_SUPPORT = ENV_TOKENS.get('GA_OPERATION_CALLBACK_EMAIL_SERVICE_SUPPORT', []) +GA_OPERATION_EMAIL_SENDER_REGISTRATION_COURSE_DAILY_REPORT = ENV_TOKENS.get('GA_OPERATION_EMAIL_SENDER_REGISTRATION_COURSE_DAILY_REPORT', None) +GA_OPERATION_CALLBACK_EMAIL_REGISTRATION_COURSE_DAILY_REPORT = ENV_TOKENS.get('GA_OPERATION_CALLBACK_EMAIL_REGISTRATION_COURSE_DAILY_REPORT', []) ##### Settings for Gacco.org's app ##### from ga_app.envs.aws import * diff --git a/lms/static/css/gacco-course.css b/lms/static/css/gacco-course.css index 7087d5704323..c1b078f73290 100644 --- a/lms/static/css/gacco-course.css +++ b/lms/static/css/gacco-course.css @@ -82,6 +82,9 @@ div.info-wrapper section.handouts ol li a:hover{ background-image: none; box-shadow: 0 1px 0 #fff inset, 0 -1px 0 rgba(0, 0, 0, 0.3) inset; } +.course-index .accordion .course-navigation .button-chapter.restricted-chapter { + background-color: #aaa; +} .course-index .accordion .course-navigation .chapter-content-container.is-open { border-bottom: 1px solid rgba(0,0,0,0.3); } @@ -196,6 +199,9 @@ div.course-wrapper > a[href="#course-accordion-modal"] span i { #modal-accordion .chapter-wrapper:focus { background-color: #fff; } +#modal-accordion .chapter-wrapper.restricted-chapter { + background-color: #aaa; +} #modal-accordion .chapter { position: relative; display: block; @@ -215,6 +221,9 @@ div.course-wrapper > a[href="#course-accordion-modal"] span i { #modal-accordion .chapter:focus { color: #666; } +#modal-accordion .chapter.restricted-chapter { + background-color: #aaa; +} #modal-accordion .chapter .icon { position: absolute; left: 20px; @@ -233,6 +242,9 @@ div.course-wrapper > a[href="#course-accordion-modal"] span i { #modal-accordion .chapter-content-container .chapter-menu { display: none; } +#modal-accordion .chapter-content-container .chapter-menu.restricted-chapter { + background-color: #aaa; +} #modal-accordion .chapter-content-container .menu-item { border-bottom: 0; border-radius: 0; @@ -278,6 +290,10 @@ div.course-wrapper > a[href="#course-accordion-modal"] span i { opacity: 1.0; right: 15px; } +#modal-accordion .chapter-content-container .menu-item.restricted-section, +#modal-accordion .chapter-content-container .menu-item.restricted-section a { + background-color: #aaa; +} @media screen and (max-width: 800px) { div.course-wrapper section.course-content { diff --git a/lms/static/sass/course/courseware/_sidebar.scss b/lms/static/sass/course/courseware/_sidebar.scss index fda4857eac3b..8da002c5bef3 100644 --- a/lms/static/sass/course/courseware/_sidebar.scss +++ b/lms/static/sass/course/courseware/_sidebar.scss @@ -129,6 +129,10 @@ color: $alert-color; } } + + .subtitle-name { + margin-right: 5px; + } } &:hover, @@ -164,6 +168,15 @@ padding-bottom: ($baseline/2); } } + .menu-item.restricted-section { + background-color: #aaa; + a { + background-color: #aaa; + } + } + } + .chapter-menu.restricted-chapter { + background-color: #aaa; } } } diff --git a/lms/templates/courseware/accordion.html b/lms/templates/courseware/accordion.html index 39263f59e00f..85919b9d7ea9 100644 --- a/lms/templates/courseware/accordion.html +++ b/lms/templates/courseware/accordion.html @@ -15,18 +15,18 @@ active_class = '' %> % if is_modal: -
      +
      % endif -
      -
      +
      % for section in chapter['sections']: -