diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b53780b91680b935ab843df5921f0acb08978fd5..4bc37482c8d90a9794647db9d74555f83e46bc2c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,10 @@ LMS: Add feature for providing background grade report generation via Celery instructor task, with reports uploaded to S3. Feature is visible on the beta instructor dashboard. LMS-58 +LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid + across all runs with the same course name. Old group membership will still work + across runs, but new beta-testers will only be added to a single course run. + LMS: Add a user-visible alert modal when a forums AJAX request fails. Blades: Add template for checkboxes response to studio. BLD-193. diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 4b50c559deaf9cbcc9d3c037dd2e14d8d511f2c1..8c4fb662f931f530eebd62195f52860c6aaeb0d8 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -241,7 +241,11 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): # Check start date if descriptor.start is not None: now = datetime.now(UTC()) - effective_start = _adjust_start_date_for_beta_testers(user, descriptor) + effective_start = _adjust_start_date_for_beta_testers( + user, + descriptor, + course_context=course_context + ) if now > effective_start: # after start date, everyone can see it debug("Allow: now > effective start date") @@ -337,7 +341,7 @@ def _dispatch(table, action, user, obj): type(obj), action)) -def _adjust_start_date_for_beta_testers(user, descriptor): +def _adjust_start_date_for_beta_testers(user, descriptor, course_context=None): """ If user is in a beta test group, adjust the start date by the appropriate number of days. @@ -364,7 +368,7 @@ def _adjust_start_date_for_beta_testers(user, descriptor): # bail early if no beta testing is set up return descriptor.start - if CourseBetaTesterRole(descriptor.location).has_user(user): + if CourseBetaTesterRole(descriptor.location, course_context=course_context).has_user(user): debug("Adjust start time: user in beta role for %s", descriptor) delta = timedelta(descriptor.days_early_for_beta) effective = descriptor.start - delta diff --git a/lms/djangoapps/courseware/roles.py b/lms/djangoapps/courseware/roles.py index 1643dd505169f11365600b73b7193e0e6511e560..110bb9f3623b6115c14ed8c4a82ee9d8a050f33e 100644 --- a/lms/djangoapps/courseware/roles.py +++ b/lms/djangoapps/courseware/roles.py @@ -187,6 +187,6 @@ class OrgStaffRole(OrgRole): class OrgInstructorRole(OrgRole): - """An organization staff member""" + """An organization instructor""" def __init__(self, *args, **kwargs): - super(OrgInstructorRole, self).__init__('staff', *args, **kwargs) + super(OrgInstructorRole, self).__init__('instructor', *args, **kwargs) diff --git a/lms/djangoapps/courseware/tests/factories.py b/lms/djangoapps/courseware/tests/factories.py index 91f91f2617425f7e93e6889a428a823f8e315c2d..38ad365011d21dda102679357c01e6783a2e0ca0 100644 --- a/lms/djangoapps/courseware/tests/factories.py +++ b/lms/djangoapps/courseware/tests/factories.py @@ -14,7 +14,14 @@ from student.tests.factories import RegistrationFactory # Imported to re-export from student.tests.factories import UserProfileFactory as StudentUserProfileFactory from courseware.models import StudentModule, XModuleUserStateSummaryField from courseware.models import XModuleStudentInfoField, XModuleStudentPrefsField -from courseware.roles import CourseInstructorRole, CourseStaffRole +from courseware.roles import ( + CourseInstructorRole, + CourseStaffRole, + CourseBetaTesterRole, + GlobalStaff, + OrgStaffRole, + OrgInstructorRole, +) from xmodule.modulestore import Location @@ -54,6 +61,59 @@ class StaffFactory(UserFactory): CourseStaffRole(extracted).add_users(self) +class BetaTesterFactory(UserFactory): + """ + Given a course Location, returns a User object with beta-tester + permissions for `course`. + """ + last_name = "Beta-Tester" + + @post_generation + def course(self, create, extracted, **kwargs): + if extracted is None: + raise ValueError("Must specify a course location for a beta-tester user") + CourseBetaTesterRole(extracted).add_users(self) + + +class OrgStaffFactory(UserFactory): + """ + Given a course Location, returns a User object with org-staff + permissions for `course`. + """ + last_name = "Org-Staff" + + @post_generation + def course(self, create, extracted, **kwargs): + if extracted is None: + raise ValueError("Must specify a course location for an org-staff user") + OrgStaffRole(extracted).add_users(self) + + +class OrgInstructorFactory(UserFactory): + """ + Given a course Location, returns a User object with org-instructor + permissions for `course`. + """ + last_name = "Org-Instructor" + + @post_generation + def course(self, create, extracted, **kwargs): + if extracted is None: + raise ValueError("Must specify a course location for an org-instructor user") + OrgInstructorRole(extracted).add_users(self) + + +class GlobalStaffFactory(UserFactory): + """ + Returns a User object with global staff access + """ + last_name = "GlobalStaff" + + @post_generation + def set_staff(self, create, extracted, **kwargs): + GlobalStaff().add_users(self) + + class StudentModuleFactory(DjangoModelFactory): FACTORY_FOR = StudentModule diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 3639d07afafc1a4b0f99bb78db0c55032efde05b..9ed0dea8e5d87e5be17b415b42e2369cf5c5181e 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -9,14 +9,23 @@ from django.test.utils import override_settings # Need access to internal func to put users in the right group from courseware.access import has_access -from courseware.roles import CourseBetaTesterRole, CourseInstructorRole, CourseStaffRole, GlobalStaff from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory + from courseware.tests.helpers import LoginEnrollmentTestCase, check_for_get_code from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +from courseware.tests.factories import ( + BetaTesterFactory, + StaffFactory, + GlobalStaffFactory, + InstructorFactory, + OrgStaffFactory, + OrgInstructorFactory, +) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @@ -89,13 +98,16 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): # user (the student), and the requesting user (the prof) url = reverse('student_progress', kwargs={'course_id': course.id, - 'student_id': User.objects.get(email=self.ACCOUNT_INFO[0][0]).id}) + 'student_id': self.enrolled_user.id}) check_for_get_code(self, 404, url) # The courseware url should redirect, not 200 url = self._reverse_urls(['courseware'], course)[0] check_for_get_code(self, 302, url) + def login(self, user): + return super(TestViewAuth, self).login(user.email, 'test') + def setUp(self): self.course = CourseFactory.create(number='999', display_name='Robot_Super_Course') @@ -103,26 +115,37 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.courseware_chapter = ItemFactory.create(display_name='courseware') self.test_course = CourseFactory.create(number='666', display_name='Robot_Sub_Course') - self.sub_courseware_chapter = ItemFactory.create(parent_location=self.test_course.location, - display_name='courseware') - self.sub_overview_chapter = ItemFactory.create(parent_location=self.sub_courseware_chapter.location, - display_name='Overview') - self.welcome_section = ItemFactory.create(parent_location=self.overview_chapter.location, - display_name='Welcome') - - # Create two accounts and activate them. - for i in range(len(self.ACCOUNT_INFO)): - username, email, password = 'u{0}'.format(i), self.ACCOUNT_INFO[i][0], self.ACCOUNT_INFO[i][1] - self.create_account(username, email, password) - self.activate_user(email) + self.other_org_course = CourseFactory.create(org='Other_Org_Course') + self.sub_courseware_chapter = ItemFactory.create( + parent_location=self.test_course.location, display_name='courseware' + ) + self.sub_overview_chapter = ItemFactory.create( + parent_location=self.sub_courseware_chapter.location, + display_name='Overview' + ) + self.welcome_section = ItemFactory.create( + parent_location=self.overview_chapter.location, + display_name='Welcome' + ) + + self.unenrolled_user = UserFactory(last_name="Unenrolled") + + self.enrolled_user = UserFactory(last_name="Enrolled") + CourseEnrollmentFactory(user=self.enrolled_user, course_id=self.course.id) + CourseEnrollmentFactory(user=self.enrolled_user, course_id=self.test_course.id) + + self.staff_user = StaffFactory(course=self.course.location) + self.instructor_user = InstructorFactory(course=self.course.location) + self.org_staff_user = OrgStaffFactory(course=self.course.location) + self.org_instructor_user = OrgInstructorFactory(course=self.course.location) + self.global_staff_user = GlobalStaffFactory() def test_redirection_unenrolled(self): """ Verify unenrolled student is redirected to the 'about' section of the chapter instead of the 'Welcome' section after clicking on the courseware tab. """ - email, password = self.ACCOUNT_INFO[0] - self.login(email, password) + self.login(self.unenrolled_user) response = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) self.assertRedirects(response, @@ -134,9 +157,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify enrolled student is redirected to the 'Welcome' section of the chapter after clicking on the courseware tab. """ - email, password = self.ACCOUNT_INFO[0] - self.login(email, password) - self.enroll(self.course) + self.login(self.enrolled_user) response = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) @@ -152,11 +173,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify non-staff cannot load the instructor dashboard, the grade views, and student profile pages. """ - email, password = self.ACCOUNT_INFO[0] - self.login(email, password) - - self.enroll(self.course) - self.enroll(self.test_course) + self.login(self.enrolled_user) urls = [reverse('instructor_dashboard', kwargs={'course_id': self.course.id}), reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id})] @@ -165,37 +182,69 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): for url in urls: check_for_get_code(self, 404, url) + def test_staff_course_access(self): + """ + Verify staff can load the staff dashboard, the grade views, + and student profile pages for their course. + """ + self.login(self.staff_user) + + # Now should be able to get to self.course, but not self.test_course + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + check_for_get_code(self, 200, url) + + url = reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id}) + check_for_get_code(self, 404, url) + def test_instructor_course_access(self): """ Verify instructor can load the instructor dashboard, the grade views, and student profile pages for their course. """ - email, password = self.ACCOUNT_INFO[1] + self.login(self.instructor_user) - # Make the instructor staff in self.course - CourseInstructorRole(self.course.location).add_users(User.objects.get(email=email)) + # Now should be able to get to self.course, but not self.test_course + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + check_for_get_code(self, 200, url) - self.login(email, password) + url = reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id}) + check_for_get_code(self, 404, url) - # Now should be able to get to self.course, but not self.test_course + def test_org_staff_access(self): + """ + Verify org staff can load the instructor dashboard, the grade views, + and student profile pages for course in their org. + """ + self.login(self.org_staff_user) url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) check_for_get_code(self, 200, url) url = reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id}) + check_for_get_code(self, 200, url) + + url = reverse('instructor_dashboard', kwargs={'course_id': self.other_org_course.id}) check_for_get_code(self, 404, url) - def test_instructor_as_staff_access(self): + def test_org_instructor_access(self): """ - Verify the instructor can load staff pages if he is given - staff permissions. + Verify org instructor can load the instructor dashboard, the grade views, + and student profile pages for course in their org. """ - email, password = self.ACCOUNT_INFO[1] - self.login(email, password) + self.login(self.org_instructor_user) + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + check_for_get_code(self, 200, url) + + url = reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id}) + check_for_get_code(self, 200, url) - # now make the instructor also staff - instructor = User.objects.get(email=email) - instructor.is_staff = True - instructor.save() + url = reverse('instructor_dashboard', kwargs={'course_id': self.other_org_course.id}) + check_for_get_code(self, 404, url) + + def test_global_staff_access(self): + """ + Verify the global staff user can access any course. + """ + self.login(self.global_staff_user) # and now should be able to load both urls = [reverse('instructor_dashboard', kwargs={'course_id': self.course.id}), @@ -211,8 +260,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): pages. """ - student_email, student_password = self.ACCOUNT_INFO[0] - # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) @@ -225,9 +272,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertFalse(self.test_course.has_started()) # First, try with an enrolled student - self.login(student_email, student_password) - self.enroll(self.course, True) - self.enroll(self.test_course, True) + self.login(self.enrolled_user) # shouldn't be able to get to anything except the light pages self._check_non_staff_light(self.course) @@ -241,8 +286,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Make sure that before course start instructors can access the page for their course. """ - instructor_email, instructor_password = self.ACCOUNT_INFO[1] - now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) course_data = {'start': tomorrow} @@ -250,11 +293,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.course = self.update_course(self.course, course_data) self.test_course = self.update_course(self.test_course, test_course_data) - # Make the instructor staff in self.course - CourseStaffRole(self.course.location).add_users(User.objects.get(email=instructor_email)) - - self.logout() - self.login(instructor_email, instructor_password) + self.login(self.instructor_user) # Enroll in the classes---can't see courseware otherwise. self.enroll(self.course, True) self.enroll(self.test_course, True) @@ -265,13 +304,11 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self._check_staff(self.course) @patch.dict('courseware.access.settings.MITX_FEATURES', {'DISABLE_START_DATES': False}) - def test_dark_launch_staff(self): + def test_dark_launch_global_staff(self): """ Make sure that before course start staff can access course pages. """ - instructor_email, instructor_password = self.ACCOUNT_INFO[1] - now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) course_data = {'start': tomorrow} @@ -279,15 +316,10 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.course = self.update_course(self.course, course_data) self.test_course = self.update_course(self.test_course, test_course_data) - self.login(instructor_email, instructor_password) + self.login(self.global_staff_user) self.enroll(self.course, True) self.enroll(self.test_course, True) - # now also make the instructor staff - instructor = User.objects.get(email=instructor_email) - instructor.is_staff = True - instructor.save() - # and now should be able to load both self._check_staff(self.course) self._check_staff(self.test_course) @@ -297,9 +329,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Check that enrollment periods work. """ - student_email, student_password = self.ACCOUNT_INFO[0] - instructor_email, instructor_password = self.ACCOUNT_INFO[1] - # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) @@ -315,52 +344,53 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.test_course = self.update_course(self.test_course, test_course_data) # First, try with an enrolled student - self.login(student_email, student_password) + self.login(self.unenrolled_user) self.assertFalse(self.enroll(self.course)) self.assertTrue(self.enroll(self.test_course)) - # Make the instructor staff in the self.course - instructor_role = CourseInstructorRole(self.course.location) - instructor_role.add_users(User.objects.get(email=instructor_email)) - self.logout() - self.login(instructor_email, instructor_password) + self.login(self.instructor_user) self.assertTrue(self.enroll(self.course)) - # now make the instructor global staff, but not in the instructor group - instructor_role.remove_users(User.objects.get(email=instructor_email)) - GlobalStaff().add_users(User.objects.get(email=instructor_email)) - # unenroll and try again - self.unenroll(self.course) + self.login(self.global_staff_user) self.assertTrue(self.enroll(self.course)) - @patch.dict('courseware.access.settings.MITX_FEATURES', {'DISABLE_START_DATES': False}) - def test_beta_period(self): - """ - Check that beta-test access works. - """ - student_email, student_password = self.ACCOUNT_INFO[0] - instructor_email, instructor_password = self.ACCOUNT_INFO[1] - # Make courses start in the future +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestBetatesterAccess(ModuleStoreTestCase): + + def setUp(self): + now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) - course_data = {'start': tomorrow} - # self.course's hasn't started - self.course = self.update_course(self.course, course_data) - self.assertFalse(self.course.has_started()) + self.course = CourseFactory(days_early_for_beta=2, start=tomorrow) + self.content = ItemFactory(parent=self.course) + + self.normal_student = UserFactory() + self.beta_tester = BetaTesterFactory(course=self.course.location) - # but should be accessible for beta testers - self.course.days_early_for_beta = 2 + @patch.dict('courseware.access.settings.MITX_FEATURES', {'DISABLE_START_DATES': False}) + def test_course_beta_period(self): + """ + Check that beta-test access works for courses. + """ + self.assertFalse(self.course.has_started()) # student user shouldn't see it - student_user = User.objects.get(email=student_email) - self.assertFalse(has_access(student_user, self.course, 'load')) + self.assertFalse(has_access(self.normal_student, self.course, 'load')) + + # now the student should see it + self.assertTrue(has_access(self.beta_tester, self.course, 'load')) - # now add the student to the beta test group - CourseBetaTesterRole(self.course.location).add_users(student_user) + @patch.dict('courseware.access.settings.MITX_FEATURES', {'DISABLE_START_DATES': False}) + def test_content_beta_period(self): + """ + Check that beta-test access works for content. + """ + # student user shouldn't see it + self.assertFalse(has_access(self.normal_student, self.content, 'load', self.course.id)) # now the student should see it - self.assertTrue(has_access(student_user, self.course, 'load')) + self.assertTrue(has_access(self.beta_tester, self.content, 'load', self.course.id))