This change drops the legacy studio custom pages UI aka. the tab edit page. This work is part of https://github.com/openedx/edx-platform/issues/36108 BREAKING CHANGE: The 'legacy_studio.custom_pages' waffle flag has been removed and the code will work as if this flag is permanently set to False. Co-authored-by: Kyle McCormick <kyle@axim.org>
2051 lines
90 KiB
Python
2051 lines
90 KiB
Python
"""
|
|
Tests for Studio Course Settings.
|
|
|
|
# TODO: Remove each `override_waffle_flag(toggles.LEGACY_STUDIO_*)` by Ulmo. For each occurance:
|
|
# * If the test case is just testing the legacy frontend, and we've got the underlying
|
|
# functionality tested elsewhere, then just delete the whole test case.
|
|
# * Otherwise (i.e., the test is using the legacy UI test a unique backend behavior), then
|
|
# rewrite the test to make assertions about the output of the Python API (preferred) or
|
|
# REST API (if necessary) so that we can delete the legacy UI without sacrificing the test
|
|
# coverage.
|
|
# Part of https://github.com/openedx/edx-platform/issues/36275.
|
|
"""
|
|
|
|
import copy
|
|
import datetime
|
|
import json
|
|
import unittest
|
|
from unittest import mock
|
|
from unittest.mock import Mock, patch
|
|
|
|
import ddt
|
|
from crum import set_current_request
|
|
from django.conf import settings
|
|
from django.test import RequestFactory
|
|
from django.test.utils import override_settings
|
|
from edx_toggles.toggles.testutils import override_waffle_flag
|
|
from milestones.models import MilestoneRelationshipType
|
|
from milestones.tests.utils import MilestonesTestCaseMixin
|
|
from pytz import UTC
|
|
|
|
from cms.djangoapps.contentstore import toggles
|
|
from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url
|
|
from cms.djangoapps.models.settings.course_grading import (
|
|
GRADING_POLICY_CHANGED_EVENT_TYPE,
|
|
CourseGradingModel,
|
|
hash_grading_policy
|
|
)
|
|
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
|
from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder
|
|
from cms.djangoapps.models.settings.waffle import MATERIAL_RECOMPUTE_ONLY_FLAG
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
|
|
from common.djangoapps.student.tests.factories import UserFactory
|
|
from common.djangoapps.util import milestones_helpers
|
|
from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
|
|
from openedx.core.djangoapps.discussions.config.waffle import (
|
|
ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND,
|
|
OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG
|
|
)
|
|
from openedx.core.djangoapps.models.course_details import CourseDetails
|
|
from openedx.core.lib.teams_config import TeamsConfig
|
|
from xmodule.fields import Date # lint-amnesty, pylint: disable=wrong-import-order
|
|
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
|
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
|
|
|
from .utils import AjaxEnabledTestClient, CourseTestCase
|
|
|
|
|
|
def get_url(course_id, handler_name='settings_handler'):
|
|
return reverse_course_url(handler_name, course_id)
|
|
|
|
|
|
class CourseSettingsEncoderTest(CourseTestCase):
|
|
"""
|
|
Tests for CourseSettingsEncoder.
|
|
"""
|
|
|
|
def test_encoder(self):
|
|
details = CourseDetails.fetch(self.course.id)
|
|
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
|
jsondetails = json.loads(jsondetails)
|
|
self.assertEqual(jsondetails['course_image_name'], self.course.course_image)
|
|
self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
|
|
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
|
|
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
|
|
self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized")
|
|
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
|
|
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
|
|
self.assertIsNone(jsondetails['language'], "language somehow initialized")
|
|
|
|
def test_pre_1900_date(self):
|
|
"""
|
|
Tests that the encoder can handle a pre-1900 date, since strftime
|
|
doesn't work for these dates.
|
|
"""
|
|
details = CourseDetails.fetch(self.course.id)
|
|
pre_1900 = datetime.datetime(1564, 4, 23, 1, 1, 1, tzinfo=UTC)
|
|
details.enrollment_start = pre_1900
|
|
dumped_jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
|
loaded_jsondetails = json.loads(dumped_jsondetails)
|
|
self.assertEqual(loaded_jsondetails['enrollment_start'], pre_1900.isoformat())
|
|
|
|
def test_ooc_encoder(self):
|
|
"""
|
|
Test the encoder out of its original constrained purpose to see if it functions for general use
|
|
"""
|
|
details = {
|
|
'number': 1,
|
|
'string': 'string',
|
|
'datetime': datetime.datetime.now(UTC)
|
|
}
|
|
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
|
jsondetails = json.loads(jsondetails)
|
|
|
|
self.assertEqual(1, jsondetails['number'])
|
|
self.assertEqual(jsondetails['string'], 'string')
|
|
|
|
|
|
@ddt.ddt
|
|
class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin):
|
|
"""
|
|
Tests for AdvanceSettings View.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.fullcourse = CourseFactory.create()
|
|
self.course_setting_url = get_url(self.course.id, 'advanced_settings_handler')
|
|
|
|
self.non_staff_client, self.nonstaff = self.create_non_staff_authed_user_client()
|
|
# "nonstaff" means "non Django staff" here. We assign this user to course staff
|
|
# role to check that even so they won't have advanced settings access when explicitly
|
|
# restricted.
|
|
CourseStaffRole(self.course.id).add_users(self.nonstaff)
|
|
|
|
@override_settings(FEATURES={'DISABLE_MOBILE_COURSE_AVAILABLE': True})
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True)
|
|
def test_mobile_field_available(self):
|
|
|
|
"""
|
|
Test to check `Mobile Course Available` field is not viewable in Studio
|
|
when DISABLE_MOBILE_COURSE_AVAILABLE is true.
|
|
"""
|
|
|
|
response = self.client.get_html(self.course_setting_url)
|
|
start = response.content.decode('utf-8').find("mobile_available")
|
|
end = response.content.decode('utf-8').find("}", start)
|
|
settings_fields = json.loads(response.content.decode('utf-8')[start + len("mobile_available: "):end + 1])
|
|
|
|
self.assertEqual(settings_fields["display_name"], "Mobile Course Available")
|
|
self.assertEqual(settings_fields["deprecated"], True)
|
|
|
|
@ddt.data(
|
|
(False, False, True),
|
|
(True, False, False),
|
|
(True, True, True),
|
|
(False, True, True)
|
|
)
|
|
@ddt.unpack
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True)
|
|
def test_discussion_fields_available(self, is_pages_and_resources_enabled,
|
|
is_legacy_discussion_setting_enabled, fields_visible):
|
|
"""
|
|
Test to check the availability of discussion related fields when relevant flags are enabled
|
|
"""
|
|
|
|
with override_waffle_flag(ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND, is_pages_and_resources_enabled):
|
|
with override_waffle_flag(OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG, is_legacy_discussion_setting_enabled):
|
|
response = self.client.get_html(self.course_setting_url).content.decode('utf-8')
|
|
self.assertEqual('allow_anonymous' in response, fields_visible)
|
|
self.assertEqual('allow_anonymous_to_peers' in response, fields_visible)
|
|
self.assertEqual('discussion_blackouts' in response, fields_visible)
|
|
self.assertEqual('discussion_topics' in response, fields_visible)
|
|
|
|
@ddt.data(False, True)
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True)
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_IMPORT, True)
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_EXPORT, True)
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True)
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True)
|
|
def test_disable_advanced_settings_feature(self, disable_advanced_settings):
|
|
"""
|
|
If this feature is enabled, only Django Staff/Superuser should be able to access the "Advanced Settings" page.
|
|
For non-staff users the "Advanced Settings" tab link should not be visible.
|
|
"""
|
|
advanced_settings_link_html = f"<a href=\"{self.course_setting_url}\">Advanced Settings</a>".encode('utf-8')
|
|
|
|
with override_settings(FEATURES={
|
|
'DISABLE_ADVANCED_SETTINGS': disable_advanced_settings,
|
|
}):
|
|
for handler in (
|
|
'import_handler',
|
|
'export_handler',
|
|
'course_team_handler',
|
|
'settings_handler',
|
|
'grading_handler',
|
|
):
|
|
# Test that non-staff users don't see the "Advanced Settings" tab link.
|
|
response = self.non_staff_client.get_html(
|
|
get_url(self.course.id, handler)
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
if disable_advanced_settings:
|
|
self.assertNotIn(advanced_settings_link_html, response.content)
|
|
else:
|
|
self.assertIn(advanced_settings_link_html, response.content)
|
|
|
|
# Test that staff users see the "Advanced Settings" tab link.
|
|
response = self.client.get_html(
|
|
get_url(self.course.id, handler)
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(advanced_settings_link_html, response.content)
|
|
|
|
# Test that non-staff users can't access the "Advanced Settings" page.
|
|
response = self.non_staff_client.get_html(self.course_setting_url)
|
|
self.assertEqual(response.status_code, 403 if disable_advanced_settings else 200)
|
|
|
|
# Test that staff users can access the "Advanced Settings" page.
|
|
response = self.client.get_html(self.course_setting_url)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
@ddt.ddt
|
|
class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin):
|
|
"""
|
|
Tests for modifying content on the first course settings page (course dates, overview, etc.).
|
|
"""
|
|
|
|
def alter_field(self, url, details, field, val):
|
|
"""
|
|
Change the one field to the given value and then invoke the update post to see if it worked.
|
|
"""
|
|
setattr(details, field, val)
|
|
# Need to partially serialize payload b/c the mock doesn't handle it correctly
|
|
payload = copy.copy(details.__dict__)
|
|
payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date)
|
|
payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date)
|
|
payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start)
|
|
payload['enrollment_end'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_end)
|
|
resp = self.client.ajax_post(url, payload)
|
|
self.compare_details_with_encoding(json.loads(resp.content.decode('utf-8')), details.__dict__, field + str(val))
|
|
|
|
MilestoneRelationshipType.objects.get_or_create(name='requires')
|
|
MilestoneRelationshipType.objects.get_or_create(name='fulfills')
|
|
|
|
@staticmethod
|
|
def convert_datetime_to_iso(datetime_obj):
|
|
"""
|
|
Use the xblock serializer to convert the datetime
|
|
"""
|
|
return Date().to_json(datetime_obj)
|
|
|
|
def test_update_and_fetch(self):
|
|
details = CourseDetails.fetch(self.course.id)
|
|
|
|
# resp s/b json from here on
|
|
url = get_url(self.course.id)
|
|
resp = self.client.get_json(url)
|
|
self.compare_details_with_encoding(json.loads(resp.content.decode('utf-8')), details.__dict__, "virgin get")
|
|
|
|
self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 12, 1, 30, tzinfo=UTC))
|
|
self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 1, 13, 30, tzinfo=UTC))
|
|
self.alter_field(url, details, 'end_date', datetime.datetime(2013, 2, 12, 1, 30, tzinfo=UTC))
|
|
self.alter_field(url, details, 'enrollment_start', datetime.datetime(2012, 10, 12, 1, 30, tzinfo=UTC))
|
|
|
|
self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012, 11, 15, 1, 30, tzinfo=UTC))
|
|
self.alter_field(url, details, 'short_description', "Short Description")
|
|
self.alter_field(url, details, 'about_sidebar_html', "About Sidebar HTML")
|
|
self.alter_field(url, details, 'overview', "Overview")
|
|
self.alter_field(url, details, 'intro_video', "intro_video")
|
|
self.alter_field(url, details, 'effort', "effort")
|
|
self.alter_field(url, details, 'course_image_name', "course_image_name")
|
|
self.alter_field(url, details, 'language', "en")
|
|
self.alter_field(url, details, 'self_paced', "true")
|
|
|
|
def compare_details_with_encoding(self, encoded, details, context):
|
|
"""
|
|
compare all of the fields of the before and after dicts
|
|
"""
|
|
self.compare_date_fields(details, encoded, context, 'start_date')
|
|
self.compare_date_fields(details, encoded, context, 'end_date')
|
|
self.compare_date_fields(details, encoded, context, 'enrollment_start')
|
|
self.compare_date_fields(details, encoded, context, 'enrollment_end')
|
|
self.assertEqual(
|
|
details['short_description'], encoded['short_description'], context + " short_description not =="
|
|
)
|
|
self.assertEqual(
|
|
details['about_sidebar_html'], encoded['about_sidebar_html'], context + " about_sidebar_html not =="
|
|
)
|
|
self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==")
|
|
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
|
|
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
|
|
self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==")
|
|
self.assertEqual(details['language'], encoded['language'], context + " languages not ==")
|
|
|
|
def compare_date_fields(self, details, encoded, context, field):
|
|
"""
|
|
Compare the given date fields between the before and after doing json deserialization
|
|
"""
|
|
if details[field] is not None:
|
|
date = Date()
|
|
if field in encoded and encoded[field] is not None:
|
|
dt1 = date.from_json(encoded[field])
|
|
dt2 = details[field]
|
|
|
|
self.assertEqual(dt1, dt2, msg=f"{dt1} != {dt2} at {context}")
|
|
else:
|
|
self.fail(field + " missing from encoded but in details at " + context)
|
|
elif field in encoded and encoded[field] is not None:
|
|
self.fail(field + " included in encoding but missing from details at " + context)
|
|
|
|
@ddt.data(
|
|
(False, False),
|
|
(True, False),
|
|
(True, True),
|
|
)
|
|
@ddt.unpack
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_upgrade_deadline(self, has_verified_mode, has_expiration_date):
|
|
if has_verified_mode:
|
|
deadline = None
|
|
if has_expiration_date:
|
|
deadline = self.course.start + datetime.timedelta(days=2)
|
|
CourseMode.objects.get_or_create(
|
|
course_id=self.course.id,
|
|
mode_display_name="Verified",
|
|
mode_slug="verified",
|
|
min_price=1,
|
|
_expiration_datetime=deadline,
|
|
)
|
|
|
|
settings_details_url = get_url(self.course.id)
|
|
response = self.client.get_html(settings_details_url)
|
|
self.assertEqual(b"Upgrade Deadline Date" in response.content, has_expiration_date and has_verified_mode)
|
|
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_pre_requisite_course_list_present(self):
|
|
settings_details_url = get_url(self.course.id)
|
|
response = self.client.get_html(settings_details_url)
|
|
self.assertContains(response, "Prerequisite Course")
|
|
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
|
|
def test_pre_requisite_course_update_and_fetch(self):
|
|
self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
|
|
msg='The initial empty state should be: no prerequisite courses')
|
|
|
|
url = get_url(self.course.id)
|
|
resp = self.client.get_json(url)
|
|
course_detail_json = json.loads(resp.content.decode('utf-8'))
|
|
# assert pre_requisite_courses is initialized
|
|
self.assertEqual([], course_detail_json['pre_requisite_courses'])
|
|
|
|
# update pre requisite courses with a new course keys
|
|
pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
|
|
pre_requisite_course2 = CourseFactory.create(org='edX', course='902', run='test_run')
|
|
pre_requisite_course_keys = [str(pre_requisite_course.id), str(pre_requisite_course2.id)]
|
|
course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
|
|
self.client.ajax_post(url, course_detail_json)
|
|
|
|
# fetch updated course to assert pre_requisite_courses has new values
|
|
resp = self.client.get_json(url)
|
|
course_detail_json = json.loads(resp.content.decode('utf-8'))
|
|
self.assertEqual(pre_requisite_course_keys, course_detail_json['pre_requisite_courses'])
|
|
|
|
self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
|
|
msg='Should have prerequisite courses')
|
|
|
|
# remove pre requisite course
|
|
course_detail_json['pre_requisite_courses'] = []
|
|
self.client.ajax_post(url, course_detail_json)
|
|
resp = self.client.get_json(url)
|
|
course_detail_json = json.loads(resp.content.decode('utf-8'))
|
|
self.assertEqual([], course_detail_json['pre_requisite_courses'])
|
|
|
|
self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
|
|
msg='Should not have prerequisite courses anymore')
|
|
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
|
|
def test_invalid_pre_requisite_course(self):
|
|
url = get_url(self.course.id)
|
|
resp = self.client.get_json(url)
|
|
course_detail_json = json.loads(resp.content.decode('utf-8'))
|
|
|
|
# update pre requisite courses one valid and one invalid key
|
|
pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
|
|
pre_requisite_course_keys = [str(pre_requisite_course.id), 'invalid_key']
|
|
course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
|
|
response = self.client.ajax_post(url, course_detail_json)
|
|
self.assertEqual(400, response.status_code)
|
|
|
|
@ddt.data(
|
|
(False, False, False),
|
|
(True, False, True),
|
|
(False, True, False),
|
|
(True, True, True),
|
|
)
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_visibility_of_entrance_exam_section(self, feature_flags):
|
|
"""
|
|
Tests entrance exam section is available if ENTRANCE_EXAMS feature is enabled no matter any other
|
|
feature is enabled or disabled i.e ENABLE_PUBLISHER.
|
|
"""
|
|
with patch.dict("django.conf.settings.FEATURES", {
|
|
'ENTRANCE_EXAMS': feature_flags[0],
|
|
'ENABLE_PUBLISHER': feature_flags[1]
|
|
}):
|
|
course_details_url = get_url(self.course.id)
|
|
resp = self.client.get_html(course_details_url)
|
|
self.assertEqual(
|
|
feature_flags[2],
|
|
b'<h3 id="heading-entrance-exam">' in resp.content
|
|
)
|
|
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_marketing_site_fetch(self):
|
|
settings_details_url = get_url(self.course.id)
|
|
|
|
with mock.patch.dict('django.conf.settings.FEATURES', {
|
|
'ENABLE_PUBLISHER': True,
|
|
'ENABLE_MKTG_SITE': True,
|
|
'ENTRANCE_EXAMS': False,
|
|
'ENABLE_PREREQUISITE_COURSES': False
|
|
}):
|
|
response = self.client.get_html(settings_details_url)
|
|
self.assertNotContains(response, "Course Summary Page")
|
|
self.assertNotContains(response, "Send a note to students via email")
|
|
self.assertContains(response, "course summary page will not be viewable")
|
|
|
|
self.assertContains(response, "Course Start Date")
|
|
self.assertContains(response, "Course End Date")
|
|
self.assertContains(response, "Enrollment Start Date")
|
|
self.assertContains(response, "Enrollment End Date")
|
|
|
|
self.assertContains(response, "Course Short Description")
|
|
self.assertNotContains(response, "Course About Sidebar HTML")
|
|
self.assertNotContains(response, "Course Title")
|
|
self.assertNotContains(response, "Course Subtitle")
|
|
self.assertNotContains(response, "Course Duration")
|
|
self.assertNotContains(response, "Course Description")
|
|
self.assertNotContains(response, "Course Overview")
|
|
self.assertNotContains(response, "Course Introduction Video")
|
|
self.assertNotContains(response, "Requirements")
|
|
self.assertNotContains(response, "Course Banner Image")
|
|
self.assertNotContains(response, "Course Video Thumbnail Image")
|
|
|
|
@unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True)
|
|
def test_entrance_exam_created_updated_and_deleted_successfully(self):
|
|
"""
|
|
This tests both of the entrance exam settings and the `any_unfulfilled_milestones` helper.
|
|
|
|
Splitting the test requires significant refactoring `settings_handler()` view.
|
|
"""
|
|
self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
|
|
msg='The initial empty state should be: no entrance exam')
|
|
|
|
settings_details_url = get_url(self.course.id)
|
|
data = {
|
|
'entrance_exam_enabled': 'true',
|
|
'entrance_exam_minimum_score_pct': '60',
|
|
'syllabus': 'none',
|
|
'short_description': 'empty',
|
|
'overview': '',
|
|
'effort': '',
|
|
'intro_video': '',
|
|
'start_date': '2012-01-01',
|
|
'end_date': '2012-12-31',
|
|
}
|
|
response = self.client.post(settings_details_url, data=json.dumps(data), content_type='application/json',
|
|
HTTP_ACCEPT='application/json')
|
|
self.assertEqual(response.status_code, 200)
|
|
course = modulestore().get_course(self.course.id)
|
|
self.assertTrue(course.entrance_exam_enabled)
|
|
self.assertEqual(course.entrance_exam_minimum_score_pct, .60)
|
|
|
|
# Update the entrance exam
|
|
data['entrance_exam_enabled'] = "true"
|
|
data['entrance_exam_minimum_score_pct'] = "80"
|
|
response = self.client.post(
|
|
settings_details_url,
|
|
data=json.dumps(data),
|
|
content_type='application/json',
|
|
HTTP_ACCEPT='application/json'
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
course = modulestore().get_course(self.course.id)
|
|
self.assertTrue(course.entrance_exam_enabled)
|
|
self.assertEqual(course.entrance_exam_minimum_score_pct, .80)
|
|
|
|
self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
|
|
msg='The entrance exam should be required.')
|
|
|
|
# Delete the entrance exam
|
|
data['entrance_exam_enabled'] = "false"
|
|
response = self.client.post(
|
|
settings_details_url,
|
|
data=json.dumps(data),
|
|
content_type='application/json',
|
|
HTTP_ACCEPT='application/json'
|
|
)
|
|
course = modulestore().get_course(self.course.id)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertFalse(course.entrance_exam_enabled)
|
|
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
|
|
if entrance_exam_minimum_score_pct.is_integer():
|
|
entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100
|
|
|
|
self.assertEqual(course.entrance_exam_minimum_score_pct, entrance_exam_minimum_score_pct)
|
|
|
|
self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
|
|
msg='The entrance exam should not be required anymore')
|
|
|
|
@unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True)
|
|
def test_entrance_exam_store_default_min_score(self):
|
|
"""
|
|
test that creating an entrance exam should store the default value, if key missing in json request
|
|
or entrance_exam_minimum_score_pct is an empty string
|
|
"""
|
|
settings_details_url = get_url(self.course.id)
|
|
test_data_1 = {
|
|
'entrance_exam_enabled': 'true',
|
|
'syllabus': 'none',
|
|
'short_description': 'empty',
|
|
'overview': '',
|
|
'effort': '',
|
|
'intro_video': '',
|
|
'start_date': '2012-01-01',
|
|
'end_date': '2012-12-31',
|
|
}
|
|
response = self.client.post(
|
|
settings_details_url,
|
|
data=json.dumps(test_data_1),
|
|
content_type='application/json',
|
|
HTTP_ACCEPT='application/json'
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
course = modulestore().get_course(self.course.id)
|
|
self.assertTrue(course.entrance_exam_enabled)
|
|
|
|
# entrance_exam_minimum_score_pct is not present in the request so default value should be saved.
|
|
self.assertEqual(course.entrance_exam_minimum_score_pct, .5)
|
|
|
|
#add entrance_exam_minimum_score_pct with empty value in json request.
|
|
test_data_2 = {
|
|
'entrance_exam_enabled': 'true',
|
|
'entrance_exam_minimum_score_pct': '',
|
|
'syllabus': 'none',
|
|
'short_description': 'empty',
|
|
'overview': '',
|
|
'effort': '',
|
|
'intro_video': '',
|
|
'start_date': '2012-01-01',
|
|
'end_date': '2012-12-31',
|
|
}
|
|
|
|
response = self.client.post(
|
|
settings_details_url,
|
|
data=json.dumps(test_data_2),
|
|
content_type='application/json',
|
|
HTTP_ACCEPT='application/json'
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
course = modulestore().get_course(self.course.id)
|
|
self.assertTrue(course.entrance_exam_enabled)
|
|
self.assertEqual(course.entrance_exam_minimum_score_pct, .5)
|
|
|
|
@unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True)
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
|
|
def test_entrance_after_changing_other_setting(self):
|
|
"""
|
|
Test entrance exam is not deactivated when prerequisites removed.
|
|
|
|
This test ensures that the entrance milestone is not deactivated after
|
|
course details are saves without pre-requisite courses active.
|
|
|
|
The test was implemented after a bug fixing, correcting the behaviour
|
|
that every time course details were saved,
|
|
if there wasn't any pre-requisite course in the POST
|
|
the view just deleted all the pre-requisite courses, including entrance exam,
|
|
despite the fact that the entrance_exam_enabled was True.
|
|
"""
|
|
assert not milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \
|
|
'The initial empty state should be: no entrance exam'
|
|
|
|
settings_details_url = get_url(self.course.id)
|
|
data = {
|
|
'entrance_exam_enabled': 'true',
|
|
'entrance_exam_minimum_score_pct': '60',
|
|
'syllabus': 'none',
|
|
'short_description': 'empty',
|
|
'overview': '',
|
|
'effort': '',
|
|
'intro_video': '',
|
|
'start_date': '2012-01-01',
|
|
'end_date': '2012-12-31',
|
|
}
|
|
response = self.client.post(
|
|
settings_details_url,
|
|
data=json.dumps(data),
|
|
content_type='application/json',
|
|
HTTP_ACCEPT='application/json'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
course = modulestore().get_course(self.course.id)
|
|
assert course.entrance_exam_enabled
|
|
assert course.entrance_exam_minimum_score_pct == .60
|
|
|
|
assert milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \
|
|
'The entrance exam should be required.'
|
|
|
|
# Call the settings handler again then ensure it didn't delete the settings of the entrance exam
|
|
data.update({
|
|
'start_date': '2018-01-01',
|
|
'end_date': '{year}-12-31'.format(year=datetime.datetime.now().year + 4),
|
|
})
|
|
response = self.client.post(
|
|
settings_details_url,
|
|
data=json.dumps(data),
|
|
content_type='application/json',
|
|
HTTP_ACCEPT='application/json'
|
|
)
|
|
assert response.status_code == 200
|
|
assert milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \
|
|
'The entrance exam should be required.'
|
|
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_editable_short_description_fetch(self):
|
|
settings_details_url = get_url(self.course.id)
|
|
|
|
with mock.patch.dict('django.conf.settings.FEATURES', {'EDITABLE_SHORT_DESCRIPTION': False}):
|
|
response = self.client.get_html(settings_details_url)
|
|
self.assertNotContains(response, "Course Short Description")
|
|
|
|
def test_empty_course_overview_keep_default_value(self):
|
|
"""
|
|
Test saving the course with an empty course overview.
|
|
|
|
If the overview is empty - the save method should use the default
|
|
value for the field.
|
|
"""
|
|
settings_details_url = get_url(self.course.id)
|
|
|
|
# add overview with empty value in json request.
|
|
test_data = {
|
|
'syllabus': 'none',
|
|
'short_description': 'test',
|
|
'overview': '',
|
|
'effort': '',
|
|
'intro_video': '',
|
|
'start_date': '2022-01-01',
|
|
'end_date': '2022-12-31',
|
|
}
|
|
|
|
response = self.client.post(
|
|
settings_details_url,
|
|
data=json.dumps(test_data),
|
|
content_type='application/json',
|
|
HTTP_ACCEPT='application/json'
|
|
)
|
|
course_details = CourseDetails.fetch(self.course.id)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(course_details.overview, '<p> </p>')
|
|
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_regular_site_fetch(self):
|
|
settings_details_url = get_url(self.course.id)
|
|
|
|
with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_PUBLISHER': False,
|
|
'ENABLE_EXTENDED_COURSE_DETAILS': True}):
|
|
response = self.client.get_html(settings_details_url)
|
|
self.assertContains(response, "Course Summary Page")
|
|
self.assertContains(response, "Send a note to students via email")
|
|
self.assertNotContains(response, "course summary page will not be viewable")
|
|
|
|
self.assertContains(response, "Course Start Date")
|
|
self.assertContains(response, "Course End Date")
|
|
self.assertContains(response, "Enrollment Start Date")
|
|
self.assertContains(response, "Enrollment End Date")
|
|
|
|
self.assertContains(response, "Introducing Your Course")
|
|
self.assertContains(response, "Course Card Image")
|
|
self.assertContains(response, "Course Title")
|
|
self.assertContains(response, "Course Subtitle")
|
|
self.assertContains(response, "Course Duration")
|
|
self.assertContains(response, "Course Description")
|
|
self.assertContains(response, "Course Short Description")
|
|
self.assertNotContains(response, "Course About Sidebar HTML")
|
|
self.assertContains(response, "Course Overview")
|
|
self.assertContains(response, "Course Introduction Video")
|
|
self.assertContains(response, "Requirements")
|
|
self.assertContains(response, "Course Banner Image")
|
|
self.assertContains(response, "Course Video Thumbnail Image")
|
|
|
|
|
|
@ddt.ddt
|
|
class CourseGradingTest(CourseTestCase):
|
|
"""
|
|
Tests for the course settings grading page.
|
|
"""
|
|
|
|
def test_initial_grader(self):
|
|
test_grader = CourseGradingModel(self.course)
|
|
self.assertIsNotNone(test_grader.graders)
|
|
self.assertIsNotNone(test_grader.grade_cutoffs)
|
|
|
|
def test_fetch_grader(self):
|
|
test_grader = CourseGradingModel.fetch(self.course.id)
|
|
self.assertIsNotNone(test_grader.graders, "No graders")
|
|
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
|
|
|
for i, grader in enumerate(test_grader.graders):
|
|
subgrader = CourseGradingModel.fetch_grader(self.course.id, i)
|
|
self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal")
|
|
|
|
@mock.patch('common.djangoapps.track.event_transaction_utils.uuid4')
|
|
@mock.patch('cms.djangoapps.models.settings.course_grading.tracker')
|
|
@mock.patch('cms.djangoapps.contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
|
|
def test_update_from_json(self, send_signal, tracker, uuid):
|
|
uuid.return_value = "mockUUID"
|
|
self.course = CourseFactory.create()
|
|
test_grader = CourseGradingModel.fetch(self.course.id)
|
|
# there should be no event raised after this call, since nothing got modified
|
|
altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
|
|
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
|
|
test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2
|
|
altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
|
|
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2")
|
|
grading_policy_2 = self._grading_policy_hash_for_course()
|
|
# test for bug LMS-11485
|
|
with modulestore().bulk_operations(self.course.id):
|
|
new_grader = test_grader.graders[0].copy()
|
|
new_grader['type'] += '_foo'
|
|
new_grader['short_label'] += '_foo'
|
|
new_grader['id'] = len(test_grader.graders)
|
|
test_grader.graders.append(new_grader)
|
|
# don't use altered cached def, get a fresh one
|
|
CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
|
|
altered_grader = CourseGradingModel.fetch(self.course.id)
|
|
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__)
|
|
grading_policy_3 = self._grading_policy_hash_for_course()
|
|
test_grader.grade_cutoffs['D'] = 0.3
|
|
altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
|
|
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
|
|
grading_policy_4 = self._grading_policy_hash_for_course()
|
|
test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
|
|
altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
|
|
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
|
|
|
|
# one for each of the calls to update_from_json()
|
|
send_signal.assert_has_calls([
|
|
# pylint: disable=line-too-long
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_2),
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_3),
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_4),
|
|
# pylint: enable=line-too-long
|
|
])
|
|
|
|
# one for each of the calls to update_from_json(); the last update doesn't actually change the parts of the
|
|
# policy that get hashed
|
|
tracker.emit.assert_has_calls([
|
|
mock.call(
|
|
GRADING_POLICY_CHANGED_EVENT_TYPE,
|
|
{
|
|
'course_id': str(self.course.id),
|
|
'event_transaction_type': 'edx.grades.grading_policy_changed',
|
|
'grading_policy_hash': policy_hash,
|
|
'user_id': str(self.user.id),
|
|
'event_transaction_id': 'mockUUID',
|
|
}
|
|
) for policy_hash in (
|
|
grading_policy_2, grading_policy_3, grading_policy_4
|
|
)
|
|
])
|
|
|
|
def test_must_fire_grading_event_and_signal_multiple_type(self):
|
|
"""
|
|
Verifies that 'must_fire_grading_event_and_signal' ignores (returns False) if we modify
|
|
short_label and or name
|
|
use test_must_fire_grading_event_and_signal_multiple_type_2_split to run this test only
|
|
"""
|
|
self.course = CourseFactory.create()
|
|
# .raw_grader approximates what our UI sends down. It uses decimal representation of percent
|
|
# without it, the weights would be percentages
|
|
raw_grader_list = modulestore().get_course(self.course.id).raw_grader
|
|
course_grading_model = CourseGradingModel.fetch(self.course.id)
|
|
raw_grader_list[0]['type'] += '_foo'
|
|
raw_grader_list[0]['short_label'] += '_foo'
|
|
raw_grader_list[2]['type'] += '_foo'
|
|
raw_grader_list[3]['type'] += '_foo'
|
|
|
|
result = CourseGradingModel.must_fire_grading_event_and_signal(
|
|
self.course.id,
|
|
raw_grader_list,
|
|
modulestore().get_course(self.course.id),
|
|
course_grading_model.__dict__
|
|
)
|
|
self.assertTrue(result)
|
|
|
|
@override_waffle_flag(MATERIAL_RECOMPUTE_ONLY_FLAG, True)
|
|
def test_must_fire_grading_event_and_signal_multiple_type_waffle_on(self):
|
|
"""
|
|
Verifies that 'must_fire_grading_event_and_signal' ignores (returns False) if we modify
|
|
short_label and or name
|
|
use test_must_fire_grading_event_and_signal_multiple_type_2_split to run this test only
|
|
"""
|
|
self.course = CourseFactory.create()
|
|
# .raw_grader approximates what our UI sends down. It uses decimal representation of percent
|
|
# without it, the weights would be percentages
|
|
raw_grader_list = modulestore().get_course(self.course.id).raw_grader
|
|
course_grading_model = CourseGradingModel.fetch(self.course.id)
|
|
raw_grader_list[0]['type'] += '_foo'
|
|
raw_grader_list[0]['short_label'] += '_foo'
|
|
raw_grader_list[2]['type'] += '_foo'
|
|
raw_grader_list[3]['type'] += '_foo'
|
|
|
|
result = CourseGradingModel.must_fire_grading_event_and_signal(
|
|
self.course.id,
|
|
raw_grader_list,
|
|
modulestore().get_course(self.course.id),
|
|
course_grading_model.__dict__
|
|
)
|
|
self.assertFalse(result)
|
|
|
|
def test_must_fire_grading_event_and_signal_return_true(self):
|
|
"""
|
|
Verifies that 'must_fire_grading_event_and_signal' ignores (returns False) if we modify
|
|
short_label and or name
|
|
use _2_split suffix to run this test only
|
|
"""
|
|
self.course = CourseFactory.create()
|
|
# .raw_grader approximates what our UI sends down. It uses decimal representation of percent
|
|
# without it, the weights would be percentages
|
|
raw_grader_list = modulestore().get_course(self.course.id).raw_grader
|
|
course_grading_model = CourseGradingModel.fetch(self.course.id)
|
|
raw_grader_list[0]['weight'] *= 2
|
|
raw_grader_list[0]['short_label'] += '_foo'
|
|
raw_grader_list[2]['type'] += '_foo'
|
|
raw_grader_list[3]['type'] += '_foo'
|
|
|
|
result = CourseGradingModel.must_fire_grading_event_and_signal(
|
|
self.course.id,
|
|
raw_grader_list,
|
|
modulestore().get_course(self.course.id),
|
|
course_grading_model.__dict__
|
|
)
|
|
self.assertTrue(result)
|
|
|
|
@mock.patch('common.djangoapps.track.event_transaction_utils.uuid4')
|
|
@mock.patch('cms.djangoapps.models.settings.course_grading.tracker')
|
|
@mock.patch('cms.djangoapps.contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
|
|
def test_update_grader_from_json(self, send_signal, tracker, uuid):
|
|
uuid.return_value = 'mockUUID'
|
|
test_grader = CourseGradingModel.fetch(self.course.id)
|
|
altered_grader = CourseGradingModel.update_grader_from_json(
|
|
self.course.id, test_grader.graders[1], self.user
|
|
)
|
|
self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
|
|
|
|
test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2
|
|
altered_grader = CourseGradingModel.update_grader_from_json(
|
|
self.course.id, test_grader.graders[1], self.user)
|
|
self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2")
|
|
grading_policy_2 = self._grading_policy_hash_for_course()
|
|
|
|
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
|
|
altered_grader = CourseGradingModel.update_grader_from_json(
|
|
self.course.id, test_grader.graders[1], self.user)
|
|
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
|
|
grading_policy_3 = self._grading_policy_hash_for_course()
|
|
|
|
# one for each of the calls to update_grader_from_json()
|
|
send_signal.assert_has_calls([
|
|
# pylint: disable=line-too-long
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_2),
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_3),
|
|
# pylint: enable=line-too-long
|
|
])
|
|
|
|
# one for each of the calls to update_grader_from_json()
|
|
tracker.emit.assert_has_calls([
|
|
mock.call(
|
|
GRADING_POLICY_CHANGED_EVENT_TYPE,
|
|
{
|
|
'course_id': str(self.course.id),
|
|
'user_id': str(self.user.id),
|
|
'grading_policy_hash': policy_hash,
|
|
'event_transaction_id': 'mockUUID',
|
|
'event_transaction_type': 'edx.grades.grading_policy_changed',
|
|
}
|
|
) for policy_hash in [grading_policy_2, grading_policy_3]
|
|
], any_order=True)
|
|
|
|
@mock.patch('common.djangoapps.track.event_transaction_utils.uuid4')
|
|
@mock.patch('cms.djangoapps.models.settings.course_grading.tracker')
|
|
def test_update_cutoffs_from_json(self, tracker, uuid):
|
|
uuid.return_value = 'mockUUID'
|
|
test_grader = CourseGradingModel.fetch(self.course.id)
|
|
CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user)
|
|
# Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json
|
|
# simply returns the cutoffs you send into it, rather than returning the db contents.
|
|
altered_grader = CourseGradingModel.fetch(self.course.id)
|
|
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
|
|
grading_policy_1 = self._grading_policy_hash_for_course()
|
|
|
|
test_grader.grade_cutoffs['D'] = 0.3
|
|
CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user)
|
|
altered_grader = CourseGradingModel.fetch(self.course.id)
|
|
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
|
|
grading_policy_2 = self._grading_policy_hash_for_course()
|
|
|
|
test_grader.grade_cutoffs['Pass'] = 0.75
|
|
CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user)
|
|
altered_grader = CourseGradingModel.fetch(self.course.id)
|
|
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
|
|
grading_policy_3 = self._grading_policy_hash_for_course()
|
|
|
|
# one for each of the calls to update_cutoffs_from_json()
|
|
tracker.emit.assert_has_calls([
|
|
mock.call(
|
|
GRADING_POLICY_CHANGED_EVENT_TYPE,
|
|
{
|
|
'course_id': str(self.course.id),
|
|
'event_transaction_type': 'edx.grades.grading_policy_changed',
|
|
'grading_policy_hash': policy_hash,
|
|
'user_id': str(self.user.id),
|
|
'event_transaction_id': 'mockUUID',
|
|
}
|
|
) for policy_hash in (grading_policy_1, grading_policy_2, grading_policy_3)
|
|
])
|
|
|
|
def test_delete_grace_period(self):
|
|
test_grader = CourseGradingModel.fetch(self.course.id)
|
|
CourseGradingModel.update_grace_period_from_json(
|
|
self.course.id, test_grader.grace_period, self.user
|
|
)
|
|
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
|
altered_grader = CourseGradingModel.fetch(self.course.id)
|
|
self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")
|
|
|
|
test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
|
|
CourseGradingModel.update_grace_period_from_json(
|
|
self.course.id, test_grader.grace_period, self.user)
|
|
altered_grader = CourseGradingModel.fetch(self.course.id)
|
|
self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period")
|
|
|
|
test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0}
|
|
# Now delete the grace period
|
|
CourseGradingModel.delete_grace_period(self.course.id, self.user)
|
|
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
|
altered_grader = CourseGradingModel.fetch(self.course.id)
|
|
# Once deleted, the grace period should simply be None
|
|
self.assertEqual(None, altered_grader.grace_period, "Delete grace period")
|
|
|
|
@mock.patch('common.djangoapps.track.event_transaction_utils.uuid4')
|
|
@mock.patch('cms.djangoapps.models.settings.course_grading.tracker')
|
|
@mock.patch('cms.djangoapps.contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
|
|
def test_update_section_grader_type(self, send_signal, tracker, uuid):
|
|
uuid.return_value = 'mockUUID'
|
|
# Get the block and the section_grader_type and assert they are the default values
|
|
block = modulestore().get_item(self.course.location)
|
|
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
|
|
|
self.assertEqual('notgraded', section_grader_type['graderType'])
|
|
self.assertEqual(None, block.format)
|
|
self.assertEqual(False, block.graded)
|
|
|
|
# Change the default grader type to Homework, which should also mark the section as graded
|
|
CourseGradingModel.update_section_grader_type(self.course, 'Homework', self.user)
|
|
block = modulestore().get_item(self.course.location)
|
|
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
|
grading_policy_1 = self._grading_policy_hash_for_course()
|
|
|
|
self.assertEqual('Homework', section_grader_type['graderType'])
|
|
self.assertEqual('Homework', block.format)
|
|
self.assertEqual(True, block.graded)
|
|
|
|
# Change the grader type back to notgraded, which should also unmark the section as graded
|
|
CourseGradingModel.update_section_grader_type(self.course, 'notgraded', self.user)
|
|
block = modulestore().get_item(self.course.location)
|
|
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
|
grading_policy_2 = self._grading_policy_hash_for_course()
|
|
|
|
self.assertEqual('notgraded', section_grader_type['graderType'])
|
|
self.assertEqual(None, block.format)
|
|
self.assertEqual(False, block.graded)
|
|
|
|
# one for each call to update_section_grader_type()
|
|
send_signal.assert_has_calls([
|
|
# pylint: disable=line-too-long
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_1),
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_2),
|
|
# pylint: enable=line-too-long
|
|
])
|
|
|
|
tracker.emit.assert_has_calls([
|
|
mock.call(
|
|
GRADING_POLICY_CHANGED_EVENT_TYPE,
|
|
{
|
|
'course_id': str(self.course.id),
|
|
'event_transaction_type': 'edx.grades.grading_policy_changed',
|
|
'grading_policy_hash': policy_hash,
|
|
'user_id': str(self.user.id),
|
|
'event_transaction_id': 'mockUUID',
|
|
}
|
|
) for policy_hash in (grading_policy_1, grading_policy_2)
|
|
])
|
|
|
|
def _model_from_url(self, url_base):
|
|
response = self.client.get_json(url_base)
|
|
return json.loads(response.content.decode('utf-8'))
|
|
|
|
def test_get_set_grader_types_ajax(self):
|
|
"""
|
|
Test creating and fetching the graders via ajax calls.
|
|
"""
|
|
grader_type_url_base = get_url(self.course.id, 'grading_handler')
|
|
whole_model = self._model_from_url(grader_type_url_base)
|
|
|
|
self.assertIn('graders', whole_model)
|
|
self.assertIn('grade_cutoffs', whole_model)
|
|
self.assertIn('grace_period', whole_model)
|
|
|
|
# test post/update whole
|
|
whole_model['grace_period'] = {'hours': 1, 'minutes': 30, 'seconds': 0}
|
|
response = self.client.ajax_post(grader_type_url_base, whole_model)
|
|
self.assertEqual(200, response.status_code)
|
|
whole_model = self._model_from_url(grader_type_url_base)
|
|
self.assertEqual(whole_model['grace_period'], {'hours': 1, 'minutes': 30, 'seconds': 0})
|
|
|
|
# test get one grader
|
|
self.assertGreater(len(whole_model['graders']), 1) # ensure test will make sense
|
|
grader_sample = self._model_from_url(grader_type_url_base + '/1')
|
|
self.assertEqual(grader_sample, whole_model['graders'][1])
|
|
|
|
@mock.patch('cms.djangoapps.contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
|
|
def test_add_delete_grader(self, send_signal):
|
|
grader_type_url_base = get_url(self.course.id, 'grading_handler')
|
|
original_model = self._model_from_url(grader_type_url_base)
|
|
|
|
# test add grader
|
|
new_grader = {
|
|
"type": "Extra Credit",
|
|
"min_count": 1,
|
|
"drop_count": 2,
|
|
"short_label": None,
|
|
"weight": 15,
|
|
}
|
|
|
|
response = self.client.ajax_post(
|
|
'{}/{}'.format(grader_type_url_base, len(original_model['graders'])),
|
|
new_grader
|
|
)
|
|
grading_policy_hash1 = self._grading_policy_hash_for_course()
|
|
self.assertEqual(200, response.status_code)
|
|
grader_sample = json.loads(response.content.decode('utf-8'))
|
|
new_grader['id'] = len(original_model['graders'])
|
|
self.assertEqual(new_grader, grader_sample)
|
|
|
|
# test deleting the original grader
|
|
response = self.client.delete(grader_type_url_base + '/1', HTTP_ACCEPT="application/json")
|
|
grading_policy_hash2 = self._grading_policy_hash_for_course()
|
|
self.assertEqual(204, response.status_code)
|
|
updated_model = self._model_from_url(grader_type_url_base)
|
|
new_grader['id'] -= 1 # one fewer and the id mutates
|
|
self.assertIn(new_grader, updated_model['graders'])
|
|
self.assertNotIn(original_model['graders'][1], updated_model['graders'])
|
|
send_signal.assert_has_calls([
|
|
# once for the POST
|
|
# pylint: disable=line-too-long
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_hash1),
|
|
# once for the DELETE
|
|
mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id, grading_policy_hash=grading_policy_hash2),
|
|
# pylint: enable=line-too-long
|
|
])
|
|
|
|
def setup_test_set_get_section_grader_ajax(self):
|
|
"""
|
|
Populate the course, grab a section, get the url for the assignment type access
|
|
"""
|
|
self.populate_course()
|
|
sections = modulestore().get_items(self.course.id, qualifiers={'category': "sequential"})
|
|
# see if test makes sense
|
|
self.assertGreater(len(sections), 0, "No sections found")
|
|
section = sections[0] # just take the first one
|
|
return reverse_usage_url('xblock_handler', section.location)
|
|
|
|
def test_set_get_section_grader_ajax(self):
|
|
"""
|
|
Test setting and getting section grades via the grade as url
|
|
"""
|
|
grade_type_url = self.setup_test_set_get_section_grader_ajax()
|
|
response = self.client.ajax_post(grade_type_url, {'graderType': 'Homework'})
|
|
self.assertEqual(200, response.status_code)
|
|
response = self.client.get_json(grade_type_url + '?fields=graderType')
|
|
self.assertEqual(json.loads(response.content.decode('utf-8')).get('graderType'), 'Homework')
|
|
# and unset
|
|
response = self.client.ajax_post(grade_type_url, {'graderType': 'notgraded'})
|
|
self.assertEqual(200, response.status_code)
|
|
response = self.client.get_json(grade_type_url + '?fields=graderType')
|
|
self.assertEqual(json.loads(response.content.decode('utf-8')).get('graderType'), 'notgraded')
|
|
|
|
def _grading_policy_hash_for_course(self):
|
|
return hash_grading_policy(modulestore().get_course(self.course.id).grading_policy)
|
|
|
|
|
|
@ddt.ddt
|
|
class CourseMetadataEditingTest(CourseTestCase):
|
|
"""
|
|
Tests for CourseMetadata.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.fullcourse = CourseFactory.create()
|
|
self.course_setting_url = get_url(self.course.id, 'advanced_settings_handler')
|
|
self.fullcourse_setting_url = get_url(self.fullcourse.id, 'advanced_settings_handler')
|
|
|
|
self.request = RequestFactory().request()
|
|
self.user = UserFactory()
|
|
self.request.user = self.user
|
|
set_current_request(self.request)
|
|
self.addCleanup(set_current_request, None)
|
|
|
|
def test_fetch_initial_fields(self):
|
|
test_model = CourseMetadata.fetch(self.course)
|
|
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
|
self.assertEqual(test_model['display_name']['value'], self.course.display_name)
|
|
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
|
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
|
self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name)
|
|
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
|
self.assertIn('showanswer', test_model, 'showanswer field ')
|
|
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': True})
|
|
def test_fetch_giturl_present(self):
|
|
"""
|
|
If feature flag ENABLE_EXPORT_GIT is on, show the setting as a non-deprecated Advanced Setting.
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertIn('giturl', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': False})
|
|
def test_fetch_giturl_not_present(self):
|
|
"""
|
|
If feature flag ENABLE_EXPORT_GIT is off, don't show the setting at all on the Advanced Settings page.
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertNotIn('giturl', test_model)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'test_proctoring_provider',
|
|
'proctortrack': {}
|
|
},
|
|
)
|
|
def test_fetch_proctoring_escalation_email_present(self):
|
|
"""
|
|
If 'proctortrack' is an available provider, show the escalation email setting
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertIn('proctoring_escalation_email', test_model)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'test_proctoring_provider',
|
|
'alternate_provider': {}
|
|
},
|
|
)
|
|
def test_fetch_proctoring_escalation_email_not_present(self):
|
|
"""
|
|
If 'proctortrack' is not an available provider, don't show the escalation email setting
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertNotIn('proctoring_escalation_email', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': False})
|
|
def test_validate_update_filtered_off(self):
|
|
"""
|
|
If feature flag is off, then giturl must be filtered.
|
|
"""
|
|
# pylint: disable=unused-variable
|
|
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"giturl": {"value": "http://example.com"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertNotIn('giturl', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': True})
|
|
def test_validate_update_filtered_on(self):
|
|
"""
|
|
If feature flag is on, then giturl must not be filtered.
|
|
"""
|
|
# pylint: disable=unused-variable
|
|
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"giturl": {"value": "http://example.com"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn('giturl', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': True})
|
|
def test_update_from_json_filtered_on(self):
|
|
"""
|
|
If feature flag is on, then giturl must be updated.
|
|
"""
|
|
test_model = CourseMetadata.update_from_json(
|
|
self.course,
|
|
{
|
|
"giturl": {"value": "http://example.com"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn('giturl', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': False})
|
|
def test_update_from_json_filtered_off(self):
|
|
"""
|
|
If feature flag is on, then giturl must not be updated.
|
|
"""
|
|
test_model = CourseMetadata.update_from_json(
|
|
self.course,
|
|
{
|
|
"giturl": {"value": "http://example.com"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertNotIn('giturl', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
|
|
def test_edxnotes_present(self):
|
|
"""
|
|
If feature flag ENABLE_EDXNOTES is on, show the setting as a non-deprecated Advanced Setting.
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertIn('edxnotes', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
|
|
def test_edxnotes_not_present(self):
|
|
"""
|
|
If feature flag ENABLE_EDXNOTES is off, don't show the setting at all on the Advanced Settings page.
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertNotIn('edxnotes', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
|
|
def test_validate_update_filtered_edxnotes_off(self):
|
|
"""
|
|
If feature flag is off, then edxnotes must be filtered.
|
|
"""
|
|
# pylint: disable=unused-variable
|
|
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"edxnotes": {"value": "true"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertNotIn('edxnotes', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
|
|
def test_validate_update_filtered_edxnotes_on(self):
|
|
"""
|
|
If feature flag is on, then edxnotes must not be filtered.
|
|
"""
|
|
# pylint: disable=unused-variable
|
|
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"edxnotes": {"value": "true"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn('edxnotes', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
|
|
def test_update_from_json_filtered_edxnotes_on(self):
|
|
"""
|
|
If feature flag is on, then edxnotes must be updated.
|
|
"""
|
|
test_model = CourseMetadata.update_from_json(
|
|
self.course,
|
|
{
|
|
"edxnotes": {"value": "true"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn('edxnotes', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
|
|
def test_update_from_json_filtered_edxnotes_off(self):
|
|
"""
|
|
If feature flag is off, then edxnotes must not be updated.
|
|
"""
|
|
test_model = CourseMetadata.update_from_json(
|
|
self.course,
|
|
{
|
|
"edxnotes": {"value": "true"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertNotIn('edxnotes', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_OTHER_COURSE_SETTINGS': True})
|
|
def test_othercoursesettings_present(self):
|
|
"""
|
|
If feature flag ENABLE_OTHER_COURSE_SETTINGS is on, show the setting in Advanced Settings.
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertIn('other_course_settings', test_model)
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_OTHER_COURSE_SETTINGS': False})
|
|
def test_othercoursesettings_not_present(self):
|
|
"""
|
|
If feature flag ENABLE_OTHER_COURSE_SETTINGS is off, don't show the setting at all in Advanced Settings.
|
|
"""
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertNotIn('other_course_settings', test_model)
|
|
|
|
def test_allow_unsupported_xblocks(self):
|
|
"""
|
|
allow_unsupported_xblocks is only shown in Advanced Settings if
|
|
XBlockStudioConfigurationFlag is enabled.
|
|
"""
|
|
self.assertNotIn('allow_unsupported_xblocks', CourseMetadata.fetch(self.fullcourse))
|
|
XBlockStudioConfigurationFlag(enabled=True).save()
|
|
self.assertIn('allow_unsupported_xblocks', CourseMetadata.fetch(self.fullcourse))
|
|
|
|
def test_validate_from_json_correct_inputs(self):
|
|
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"advertised_start": {"value": "start A"},
|
|
"days_early_for_beta": {"value": 2},
|
|
"advanced_modules": {"value": ['notes']},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertTrue(is_valid)
|
|
self.assertEqual(len(errors), 0)
|
|
self.update_check(test_model)
|
|
|
|
# Tab gets tested in test_advanced_settings_munge_tabs
|
|
self.assertIn('advanced_modules', test_model, 'Missing advanced_modules')
|
|
self.assertEqual(test_model['advanced_modules']['value'], ['notes'], 'advanced_module is not updated')
|
|
|
|
def test_validate_from_json_wrong_inputs(self):
|
|
# input incorrectly formatted data
|
|
is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"advertised_start": {"value": 1, "display_name": "Course Advertised Start Date", },
|
|
"days_early_for_beta": {"value": "supposed to be an integer",
|
|
"display_name": "Days Early for Beta Users", },
|
|
"advanced_modules": {"value": 1, "display_name": "Advanced Module List", },
|
|
},
|
|
user=self.user
|
|
)
|
|
|
|
# Check valid results from validate_and_update_from_json
|
|
self.assertFalse(is_valid)
|
|
self.assertEqual(len(errors), 3)
|
|
self.assertFalse(test_model)
|
|
|
|
error_keys = {error_obj['model']['display_name'] for error_obj in errors}
|
|
test_keys = {'Advanced Module List', 'Course Advertised Start Date', 'Days Early for Beta Users'}
|
|
self.assertEqual(error_keys, test_keys)
|
|
|
|
# try fresh fetch to ensure no update happened
|
|
fresh = modulestore().get_course(self.course.id)
|
|
test_model = CourseMetadata.fetch(fresh)
|
|
|
|
self.assertNotEqual(test_model['advertised_start']['value'], 1,
|
|
'advertised_start should not be updated to a wrong value')
|
|
self.assertNotEqual(test_model['days_early_for_beta']['value'], "supposed to be an integer",
|
|
'days_early_for beta should not be updated to a wrong value')
|
|
|
|
def test_correct_http_status(self):
|
|
json_data = json.dumps({
|
|
"advertised_start": {"value": 1, "display_name": "Course Advertised Start Date", },
|
|
"days_early_for_beta": {
|
|
"value": "supposed to be an integer",
|
|
"display_name": "Days Early for Beta Users",
|
|
},
|
|
"advanced_modules": {"value": 1, "display_name": "Advanced Module List", },
|
|
})
|
|
response = self.client.ajax_post(self.course_setting_url, json_data)
|
|
self.assertEqual(400, response.status_code)
|
|
|
|
def test_update_from_json(self):
|
|
test_model = CourseMetadata.update_from_json(
|
|
self.course,
|
|
{
|
|
"advertised_start": {"value": "start A"},
|
|
"days_early_for_beta": {"value": 2},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.update_check(test_model)
|
|
# try fresh fetch to ensure persistence
|
|
fresh = modulestore().get_course(self.course.id)
|
|
test_model = CourseMetadata.fetch(fresh)
|
|
self.update_check(test_model)
|
|
# now change some of the existing metadata
|
|
test_model = CourseMetadata.update_from_json(
|
|
fresh,
|
|
{
|
|
"advertised_start": {"value": "start B"},
|
|
"display_name": {"value": "jolly roger"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
|
self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value")
|
|
self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
|
|
self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value")
|
|
|
|
def update_check(self, test_model):
|
|
"""
|
|
checks that updates were made
|
|
"""
|
|
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
|
self.assertEqual(test_model['display_name']['value'], self.course.display_name)
|
|
self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
|
|
self.assertEqual(test_model['advertised_start']['value'], 'start A', "advertised_start not expected value")
|
|
self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
|
|
self.assertEqual(test_model['days_early_for_beta']['value'], 2, "days_early_for_beta not expected value")
|
|
|
|
def test_http_fetch_initial_fields(self):
|
|
response = self.client.get_json(self.course_setting_url)
|
|
test_model = json.loads(response.content.decode('utf-8'))
|
|
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
|
self.assertEqual(test_model['display_name']['value'], self.course.display_name)
|
|
|
|
response = self.client.get_json(self.fullcourse_setting_url)
|
|
test_model = json.loads(response.content.decode('utf-8'))
|
|
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
|
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
|
self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name)
|
|
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
|
self.assertIn('showanswer', test_model, 'showanswer field ')
|
|
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
|
|
|
def test_http_update_from_json(self):
|
|
response = self.client.ajax_post(self.course_setting_url, {
|
|
"advertised_start": {"value": "start A"},
|
|
"days_early_for_beta": {"value": 2},
|
|
})
|
|
test_model = json.loads(response.content.decode('utf-8'))
|
|
self.update_check(test_model)
|
|
|
|
response = self.client.get_json(self.course_setting_url)
|
|
test_model = json.loads(response.content.decode('utf-8'))
|
|
self.update_check(test_model)
|
|
# now change some of the existing metadata
|
|
response = self.client.ajax_post(self.course_setting_url, {
|
|
"advertised_start": {"value": "start B"},
|
|
"display_name": {"value": "jolly roger"}
|
|
})
|
|
test_model = json.loads(response.content.decode('utf-8'))
|
|
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
|
self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value")
|
|
self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
|
|
self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value")
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
|
|
@patch('xmodule.util.xmodule_django.get_current_request')
|
|
def test_post_settings_with_staff_not_enrolled(self, mock_request):
|
|
"""
|
|
Tests that we can post advance settings when course staff is not enrolled.
|
|
"""
|
|
mock_request.return_value = Mock(META={'HTTP_HOST': 'localhost'})
|
|
user = UserFactory.create(is_staff=True)
|
|
CourseStaffRole(self.course.id).add_users(user)
|
|
|
|
client = AjaxEnabledTestClient()
|
|
client.login(username=user.username, password=user.password)
|
|
response = self.client.ajax_post(self.course_setting_url, {
|
|
'advanced_modules': {"value": [""]}
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
@ddt.data(True, False)
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'test_proctoring_provider',
|
|
'valid_provider': {}
|
|
},
|
|
PARTNER_SUPPORT_EMAIL='support@foobar.com'
|
|
)
|
|
def test_validate_update_does_not_allow_proctoring_provider_changes_after_course_start(self, staff_user):
|
|
"""
|
|
Course staff cannot modify proctoring provider after the course start date.
|
|
Only admin users may update the provider if the course has started.
|
|
"""
|
|
field_name = "proctoring_provider"
|
|
course = CourseFactory.create(start=datetime.datetime.now(UTC) - datetime.timedelta(days=1))
|
|
user = UserFactory.create(is_staff=staff_user)
|
|
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
course,
|
|
{
|
|
field_name: {"value": 'valid_provider'},
|
|
},
|
|
user=user
|
|
)
|
|
|
|
if staff_user:
|
|
self.assertTrue(did_validate)
|
|
self.assertEqual(len(errors), 0)
|
|
self.assertIn(field_name, test_model)
|
|
else:
|
|
self.assertFalse(did_validate)
|
|
self.assertEqual(len(errors), 1)
|
|
self.assertEqual(
|
|
errors[0].get('message'),
|
|
(
|
|
'The proctoring provider cannot be modified after a course has started.'
|
|
' Contact support@foobar.com for assistance'
|
|
)
|
|
)
|
|
self.assertIsNone(test_model)
|
|
|
|
@ddt.data(True, False)
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'test_proctoring_provider',
|
|
'test_proctoring_provider': {},
|
|
'proctortrack': {}
|
|
},
|
|
)
|
|
def test_validate_update_requires_escalation_email_for_proctortrack(self, include_blank_email):
|
|
json_data = {
|
|
"proctoring_provider": {"value": 'proctortrack'},
|
|
}
|
|
if include_blank_email:
|
|
json_data["proctoring_escalation_email"] = {"value": ""}
|
|
|
|
course = CourseFactory.create()
|
|
CourseMetadata.update_from_dict({"enable_proctored_exams": True}, course, self.user)
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
course,
|
|
json_data,
|
|
user=self.user
|
|
)
|
|
self.assertFalse(did_validate)
|
|
self.assertEqual(len(errors), 1)
|
|
self.assertIsNone(test_model)
|
|
self.assertEqual(
|
|
errors[0].get('message'),
|
|
'Provider \'proctortrack\' requires an exam escalation contact.'
|
|
)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'test_proctoring_provider',
|
|
'test_proctoring_provider': {},
|
|
'proctortrack': {}
|
|
}
|
|
)
|
|
def test_validate_update_does_not_require_escalation_email_by_default(self):
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"proctoring_provider": {"value": "test_proctoring_provider"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertTrue(did_validate)
|
|
self.assertEqual(len(errors), 0)
|
|
self.assertIn('proctoring_provider', test_model)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'proctortrack',
|
|
'proctortrack': {}
|
|
},
|
|
)
|
|
def test_validate_update_cannot_unset_escalation_email_when_proctortrack_is_provider(self):
|
|
course = CourseFactory.create()
|
|
CourseMetadata.update_from_dict(
|
|
{"proctoring_provider": 'proctortrack', "enable_proctored_exams": True},
|
|
course,
|
|
self.user
|
|
)
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
course,
|
|
{
|
|
"proctoring_escalation_email": {"value": ""},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertFalse(did_validate)
|
|
self.assertEqual(len(errors), 1)
|
|
self.assertIsNone(test_model)
|
|
self.assertEqual(
|
|
errors[0].get('message'),
|
|
'Provider \'proctortrack\' requires an exam escalation contact.'
|
|
)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'proctortrack',
|
|
'proctortrack': {}
|
|
}
|
|
)
|
|
def test_validate_update_set_proctortrack_provider_with_valid_escalation_email(self):
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"proctoring_provider": {"value": "proctortrack"},
|
|
"proctoring_escalation_email": {"value": "foo@bar.com"},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertTrue(did_validate)
|
|
self.assertEqual(len(errors), 0)
|
|
self.assertIn('proctoring_provider', test_model)
|
|
self.assertIn('proctoring_escalation_email', test_model)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'proctortrack',
|
|
'proctortrack': {}
|
|
}
|
|
)
|
|
def test_validate_update_disable_proctoring_with_no_escalation_email(self):
|
|
course = CourseFactory.create()
|
|
CourseMetadata.update_from_dict(
|
|
{"proctoring_provider": 'proctortrack', "proctoring_escalation_email": '', "enable_proctored_exams": True},
|
|
course,
|
|
self.user
|
|
)
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
course,
|
|
{
|
|
"enable_proctored_exams": {"value": False},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertTrue(did_validate)
|
|
self.assertEqual(len(errors), 0)
|
|
self.assertIn('enable_proctored_exams', test_model)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'proctortrack',
|
|
'proctortrack': {}
|
|
}
|
|
)
|
|
def test_validate_update_disable_proctoring_and_change_escalation_email(self):
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
"proctoring_provider": {"value": "proctortrack"},
|
|
"proctoring_escalation_email": {"value": ""},
|
|
"enable_proctored_exams": {"value": False},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertTrue(did_validate)
|
|
self.assertEqual(len(errors), 0)
|
|
self.assertIn('proctoring_provider', test_model)
|
|
self.assertIn('proctoring_escalation_email', test_model)
|
|
self.assertIn('enable_proctored_exams', test_model)
|
|
|
|
@override_settings(
|
|
PROCTORING_BACKENDS={
|
|
'DEFAULT': 'proctortrack',
|
|
'proctortrack': {}
|
|
}
|
|
)
|
|
def test_validate_update_disabled_proctoring_and_unset_escalation_email(self):
|
|
course = CourseFactory.create()
|
|
CourseMetadata.update_from_dict(
|
|
{"proctoring_provider": 'proctortrack', "enable_proctored_exams": False},
|
|
course,
|
|
self.user
|
|
)
|
|
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
|
|
course,
|
|
{
|
|
"proctoring_escalation_email": {"value": ""},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertTrue(did_validate)
|
|
self.assertEqual(len(errors), 0)
|
|
self.assertIn('proctoring_provider', test_model)
|
|
self.assertIn('proctoring_escalation_email', test_model)
|
|
self.assertIn('enable_proctored_exams', test_model)
|
|
|
|
def test_create_zendesk_tickets_present_for_edx_staff(self):
|
|
"""
|
|
Tests that create zendesk tickets field is not filtered out when the user is an edX staff member.
|
|
"""
|
|
self._set_request_user_to_staff()
|
|
|
|
test_model = CourseMetadata.fetch(self.fullcourse)
|
|
self.assertIn('create_zendesk_tickets', test_model)
|
|
|
|
def test_validate_update_does_not_filter_out_create_zendesk_tickets_for_edx_staff(self):
|
|
"""
|
|
Tests that create zendesk tickets field is returned by validate_and_update_from_json method when
|
|
the user is an edX staff member.
|
|
"""
|
|
self._set_request_user_to_staff()
|
|
|
|
field_name = "create_zendesk_tickets"
|
|
|
|
_, _, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
field_name: {"value": True},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn(field_name, test_model)
|
|
|
|
def test_update_from_json_does_not_filter_out_create_zendesk_tickets_for_edx_staff(self):
|
|
"""
|
|
Tests that create zendesk tickets field is returned by update_from_json method when
|
|
the user is an edX staff member.
|
|
"""
|
|
self._set_request_user_to_staff()
|
|
|
|
field_name = "create_zendesk_tickets"
|
|
|
|
test_model = CourseMetadata.update_from_json(
|
|
self.course,
|
|
{
|
|
field_name: {"value": True},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn(field_name, test_model)
|
|
|
|
def test_validate_update_does_not_filter_out_create_zendesk_tickets_for_course_staff(self):
|
|
"""
|
|
Tests that create zendesk tickets field is not returned by validate_and_update_from_json method when
|
|
the user is not an edX staff member.
|
|
"""
|
|
field_name = "create_zendesk_tickets"
|
|
|
|
_, _, test_model = CourseMetadata.validate_and_update_from_json(
|
|
self.course,
|
|
{
|
|
field_name: {"value": True},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn(field_name, test_model)
|
|
|
|
def test_update_from_json_does_not_filter_out_create_zendesk_tickets_for_course_staff(self):
|
|
"""
|
|
Tests that create zendesk tickets field is not returned by update_from_json method when
|
|
the user is not an edX staff member.
|
|
"""
|
|
field_name = "create_zendesk_tickets"
|
|
|
|
test_model = CourseMetadata.update_from_json(
|
|
self.course,
|
|
{
|
|
field_name: {"value": True},
|
|
},
|
|
user=self.user
|
|
)
|
|
self.assertIn(field_name, test_model)
|
|
|
|
def _set_request_user_to_staff(self):
|
|
"""
|
|
Update the current request's user to be an edX staff member.
|
|
"""
|
|
self.user.is_staff = True
|
|
self.request.user = self.user
|
|
set_current_request(self.request)
|
|
|
|
def test_team_content_groups_off(self):
|
|
"""
|
|
Tests that user_partition_id is not added to the model when content groups for teams are off.
|
|
"""
|
|
course = CourseFactory.create(
|
|
teams_configuration=TeamsConfig({
|
|
'max_team_size': 2,
|
|
'team_sets': [{
|
|
'id': 'arbitrary-topic-id',
|
|
'name': 'arbitrary-topic-name',
|
|
'description': 'arbitrary-topic-desc'
|
|
}]
|
|
})
|
|
)
|
|
settings_dict = {
|
|
"teams_configuration": {
|
|
"value": {
|
|
"max_team_size": 2,
|
|
"team_sets": [
|
|
{
|
|
"id": "topic_3_id",
|
|
"name": "Topic 3 Name",
|
|
"description": "Topic 3 desc"
|
|
},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
_, errors, updated_data = CourseMetadata.validate_and_update_from_json(
|
|
course,
|
|
settings_dict,
|
|
user=self.user
|
|
)
|
|
|
|
self.assertEqual(len(errors), 0)
|
|
for team_set in updated_data["teams_configuration"]["value"]["team_sets"]:
|
|
self.assertNotIn("user_partition_id", team_set)
|
|
|
|
@patch("cms.djangoapps.models.settings.course_metadata.CONTENT_GROUPS_FOR_TEAMS.is_enabled", lambda _: True)
|
|
def test_team_content_groups_on(self):
|
|
"""
|
|
Tests that user_partition_id is added to the model when content groups for teams are on.
|
|
"""
|
|
course = CourseFactory.create(
|
|
teams_configuration=TeamsConfig({
|
|
'max_team_size': 2,
|
|
'team_sets': [{
|
|
'id': 'arbitrary-topic-id',
|
|
'name': 'arbitrary-topic-name',
|
|
'description': 'arbitrary-topic-desc'
|
|
}]
|
|
})
|
|
)
|
|
settings_dict = {
|
|
"teams_configuration": {
|
|
"value": {
|
|
"max_team_size": 2,
|
|
"team_sets": [
|
|
{
|
|
"id": "topic_3_id",
|
|
"name": "Topic 3 Name",
|
|
"description": "Topic 3 desc"
|
|
},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
_, errors, updated_data = CourseMetadata.validate_and_update_from_json(
|
|
course,
|
|
settings_dict,
|
|
user=self.user
|
|
)
|
|
|
|
self.assertEqual(len(errors), 0)
|
|
for team_set in updated_data["teams_configuration"]["value"]["team_sets"]:
|
|
self.assertIn("user_partition_id", team_set)
|
|
|
|
|
|
class CourseGraderUpdatesTest(CourseTestCase):
|
|
"""
|
|
Test getting, deleting, adding, & updating graders
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""Compute the url to use in tests"""
|
|
super().setUp()
|
|
self.url = get_url(self.course.id, 'grading_handler')
|
|
self.starting_graders = CourseGradingModel(self.course).graders
|
|
|
|
def test_get(self):
|
|
"""Test getting a specific grading type record."""
|
|
resp = self.client.get_json(self.url + '/0')
|
|
self.assertEqual(resp.status_code, 200)
|
|
obj = json.loads(resp.content.decode('utf-8'))
|
|
self.assertEqual(self.starting_graders[0], obj)
|
|
|
|
def test_delete(self):
|
|
"""Test deleting a specific grading type record."""
|
|
resp = self.client.delete(self.url + '/0', HTTP_ACCEPT="application/json")
|
|
self.assertEqual(resp.status_code, 204)
|
|
current_graders = CourseGradingModel.fetch(self.course.id).graders
|
|
self.assertNotIn(self.starting_graders[0], current_graders)
|
|
self.assertEqual(len(self.starting_graders) - 1, len(current_graders))
|
|
|
|
def test_update(self):
|
|
"""Test updating a specific grading type record."""
|
|
grader = {
|
|
"id": 0,
|
|
"type": "manual",
|
|
"min_count": 5,
|
|
"drop_count": 10,
|
|
"short_label": "yo momma",
|
|
"weight": 17.3,
|
|
}
|
|
resp = self.client.ajax_post(self.url + '/0', grader)
|
|
self.assertEqual(resp.status_code, 200)
|
|
obj = json.loads(resp.content.decode('utf-8'))
|
|
self.assertEqual(obj, grader)
|
|
current_graders = CourseGradingModel.fetch(self.course.id).graders
|
|
self.assertEqual(len(self.starting_graders), len(current_graders))
|
|
|
|
def test_add(self):
|
|
"""Test adding a grading type record."""
|
|
# the same url works for changing the whole grading model (graceperiod, cutoffs, and grading types) when
|
|
# the grading_index is None; thus, using None to imply adding a grading_type doesn't work; so, it uses an
|
|
# index out of bounds to imply create item.
|
|
grader = {
|
|
"type": "manual",
|
|
"min_count": 5,
|
|
"drop_count": 10,
|
|
"short_label": "yo momma",
|
|
"weight": 17.3,
|
|
}
|
|
resp = self.client.ajax_post(f'{self.url}/{len(self.starting_graders) + 1}', grader)
|
|
self.assertEqual(resp.status_code, 200)
|
|
obj = json.loads(resp.content.decode('utf-8'))
|
|
self.assertEqual(obj['id'], len(self.starting_graders))
|
|
del obj['id']
|
|
self.assertEqual(obj, grader)
|
|
current_graders = CourseGradingModel.fetch(self.course.id).graders
|
|
self.assertEqual(len(self.starting_graders) + 1, len(current_graders))
|
|
|
|
|
|
class CourseEnrollmentEndFieldTest(CourseTestCase):
|
|
"""
|
|
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.
|
|
"""
|
|
|
|
NOT_EDITABLE_HELPER_MESSAGE = "Contact your edX partner manager to update these settings."
|
|
NOT_EDITABLE_DATE_WRAPPER = "<div class=\"field date is-not-editable\" id=\"field-enrollment-end-date\">"
|
|
NOT_EDITABLE_TIME_WRAPPER = "<div class=\"field time is-not-editable\" id=\"field-enrollment-end-time\">"
|
|
NOT_EDITABLE_DATE_FIELD = "<input type=\"text\" class=\"end-date date end\" \
|
|
id=\"course-enrollment-end-date\" placeholder=\"MM/DD/YYYY\" autocomplete=\"off\" readonly aria-readonly=\"true\" />"
|
|
NOT_EDITABLE_TIME_FIELD = "<input type=\"text\" class=\"time end\" id=\"course-enrollment-end-time\" \
|
|
value=\"\" placeholder=\"HH:MM\" autocomplete=\"off\" readonly aria-readonly=\"true\" />"
|
|
|
|
EDITABLE_DATE_WRAPPER = "<div class=\"field date \" id=\"field-enrollment-end-date\">"
|
|
EDITABLE_TIME_WRAPPER = "<div class=\"field time \" id=\"field-enrollment-end-time\">"
|
|
EDITABLE_DATE_FIELD = "<input type=\"text\" class=\"end-date date end\" \
|
|
id=\"course-enrollment-end-date\" placeholder=\"MM/DD/YYYY\" autocomplete=\"off\" />"
|
|
EDITABLE_TIME_FIELD = "<input type=\"text\" class=\"time end\" \
|
|
id=\"course-enrollment-end-time\" value=\"\" placeholder=\"HH:MM\" autocomplete=\"off\" />"
|
|
|
|
EDITABLE_ELEMENTS = [
|
|
EDITABLE_DATE_WRAPPER,
|
|
EDITABLE_TIME_WRAPPER,
|
|
EDITABLE_DATE_FIELD,
|
|
EDITABLE_TIME_FIELD,
|
|
]
|
|
|
|
NOT_EDITABLE_ELEMENTS = [
|
|
NOT_EDITABLE_HELPER_MESSAGE,
|
|
NOT_EDITABLE_DATE_WRAPPER,
|
|
NOT_EDITABLE_TIME_WRAPPER,
|
|
NOT_EDITABLE_DATE_FIELD,
|
|
NOT_EDITABLE_TIME_FIELD,
|
|
]
|
|
|
|
def setUp(self):
|
|
"""
|
|
Initialize course used to test enrollment fields.
|
|
"""
|
|
super().setUp()
|
|
self.course = CourseFactory.create(org='edX', number='dummy', display_name='Marketing Site Course')
|
|
self.course_details_url = reverse_course_url('settings_handler', str(self.course.id))
|
|
|
|
def _get_course_details_response(self, global_staff):
|
|
"""
|
|
Return the course details page as either global or non-global staff
|
|
"""
|
|
user = UserFactory(is_staff=global_staff, password=self.TEST_PASSWORD)
|
|
CourseInstructorRole(self.course.id).add_users(user)
|
|
|
|
self.client.login(username=user.username, password=self.TEST_PASSWORD)
|
|
|
|
return self.client.get_html(self.course_details_url)
|
|
|
|
def _verify_editable(self, response):
|
|
"""
|
|
Verify that the response has expected editable fields.
|
|
|
|
Assert that all editable field content exists and no
|
|
uneditable field content exists for enrollment end fields.
|
|
"""
|
|
self.assertEqual(response.status_code, 200)
|
|
for element in self.NOT_EDITABLE_ELEMENTS:
|
|
self.assertNotContains(response, element)
|
|
|
|
for element in self.EDITABLE_ELEMENTS:
|
|
self.assertContains(response, element)
|
|
|
|
def _verify_not_editable(self, response):
|
|
"""
|
|
Verify that the response has expected non-editable fields.
|
|
|
|
Assert that all uneditable field content exists and no
|
|
editable field content exists for enrollment end fields.
|
|
"""
|
|
self.assertEqual(response.status_code, 200)
|
|
for element in self.NOT_EDITABLE_ELEMENTS:
|
|
self.assertContains(response, element)
|
|
|
|
for element in self.EDITABLE_ELEMENTS:
|
|
self.assertNotContains(response, element)
|
|
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': False})
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_course_details_with_disabled_setting_global_staff(self):
|
|
"""
|
|
Test that user enrollment end date is editable in response.
|
|
|
|
Feature flag 'ENABLE_PUBLISHER' is not enabled.
|
|
User is global staff.
|
|
"""
|
|
self._verify_editable(self._get_course_details_response(True))
|
|
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': False})
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_course_details_with_disabled_setting_non_global_staff(self):
|
|
"""
|
|
Test that user enrollment end date is editable in response.
|
|
|
|
Feature flag 'ENABLE_PUBLISHER' is not enabled.
|
|
User is non-global staff.
|
|
"""
|
|
self._verify_editable(self._get_course_details_response(False))
|
|
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': True})
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_course_details_with_enabled_setting_global_staff(self):
|
|
"""
|
|
Test that user enrollment end date is editable in response.
|
|
|
|
Feature flag 'ENABLE_PUBLISHER' is enabled.
|
|
User is global staff.
|
|
"""
|
|
self._verify_editable(self._get_course_details_response(True))
|
|
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': True})
|
|
@override_settings(PLATFORM_NAME='edX')
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
|
|
def test_course_details_with_enabled_setting_non_global_staff(self):
|
|
"""
|
|
Test that user enrollment end date is not editable in response.
|
|
|
|
Feature flag 'ENABLE_PUBLISHER' is enabled.
|
|
User is non-global staff.
|
|
"""
|
|
self._verify_not_editable(self._get_course_details_response(False))
|