test_course_settings.py 62.3 KB
Newer Older
cahrens committed
1 2 3
"""
Tests for Studio Course Settings.
"""
4
import copy
5 6
import datetime
import json
7
import unittest
8

9 10
import ddt
import mock
11
from django.conf import settings
12
from django.test.utils import override_settings
13
from pytz import UTC
14 15
from milestones.tests.utils import MilestonesTestCaseMixin
from mock import Mock, patch
16 17

from contentstore.utils import reverse_course_url, reverse_usage_url
Ahmed Jazzar committed
18
from milestones.models import MilestoneRelationshipType
19
from models.settings.course_grading import CourseGradingModel, GRADING_POLICY_CHANGED_EVENT_TYPE, hash_grading_policy
20 21
from models.settings.course_metadata import CourseMetadata
from models.settings.encoder import CourseSettingsEncoder
22
from openedx.core.djangoapps.models.course_details import CourseDetails
23
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
24
from student.roles import CourseInstructorRole, CourseStaffRole
25
from student.tests.factories import UserFactory
Ahmed Jazzar committed
26
from util import milestones_helpers
27
from xblock_django.models import XBlockStudioConfigurationFlag
28
from xmodule.fields import Date
29 30 31
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory
32
from xmodule.tabs import InvalidTabsException
Calen Pennington committed
33

34
from .utils import AjaxEnabledTestClient, CourseTestCase
35

36

37 38 39
def get_url(course_id, handler_name='settings_handler'):
    return reverse_course_url(handler_name, course_id)

40

41
class CourseSettingsEncoderTest(CourseTestCase):
cahrens committed
42
    """
43
    Tests for CourseSettingsEncoder.
cahrens committed
44
    """
45
    def test_encoder(self):
46
        details = CourseDetails.fetch(self.course.id)
Don Mitchell committed
47
        jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
48
        jsondetails = json.loads(jsondetails)
49
        self.assertEqual(jsondetails['course_image_name'], self.course.course_image)
50 51 52 53 54 55
        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")
56
        self.assertIsNone(jsondetails['language'], "language somehow initialized")
Calen Pennington committed
57

58 59 60 61 62 63
    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)
64
        pre_1900 = datetime.datetime(1564, 4, 23, 1, 1, 1, tzinfo=UTC)
65 66 67 68 69
        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())

70 71 72 73
    def test_ooc_encoder(self):
        """
        Test the encoder out of its original constrained purpose to see if it functions for general use
        """
David Baumgold committed
74 75 76
        details = {
            'number': 1,
            'string': 'string',
77
            'datetime': datetime.datetime.now(UTC)
David Baumgold committed
78
        }
79 80 81 82 83 84
        jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
        jsondetails = json.loads(jsondetails)

        self.assertEquals(1, jsondetails['number'])
        self.assertEqual(jsondetails['string'], 'string')

85 86

@ddt.ddt
87
class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin):
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
    """
    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), details.__dict__, field + str(val))

Ahmed Jazzar committed
105 106 107
        MilestoneRelationshipType.objects.get_or_create(name='requires')
        MilestoneRelationshipType.objects.get_or_create(name='fulfills')

108 109 110 111 112 113 114
    @staticmethod
    def convert_datetime_to_iso(datetime_obj):
        """
        Use the xblock serializer to convert the datetime
        """
        return Date().to_json(datetime_obj)

115
    def test_update_and_fetch(self):
116
        SelfPacedConfiguration(enabled=True).save()
117 118 119 120 121 122 123
        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), details.__dict__, "virgin get")

124 125 126 127
        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))
128

129
        self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012, 11, 15, 1, 30, tzinfo=UTC))
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
        self.alter_field(url, details, 'short_description', "Short Description")
        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')
146
        self.assertEqual(
147
            details['short_description'], encoded['short_description'], context + " short_description not =="
148
        )
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170
        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="{} != {} at {}".format(dt1, dt2, 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)

171
    @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
172 173 174 175 176
    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")

177
    @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
178
    def test_pre_requisite_course_update_and_fetch(self):
Ahmed Jazzar committed
179 180 181
        self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
                         msg='The initial empty state should be: no prerequisite courses')

182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
        url = get_url(self.course.id)
        resp = self.client.get_json(url)
        course_detail_json = json.loads(resp.content)
        # 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 = [unicode(pre_requisite_course.id), unicode(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)
        self.assertEqual(pre_requisite_course_keys, course_detail_json['pre_requisite_courses'])

Ahmed Jazzar committed
200 201 202
        self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
                        msg='Should have prerequisite courses')

203 204 205 206 207 208 209
        # 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)
        self.assertEqual([], course_detail_json['pre_requisite_courses'])

Ahmed Jazzar committed
210 211 212
        self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
                         msg='Should not have prerequisite courses anymore')

213
    @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
    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)

        # 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 = [unicode(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),
    )
232
    @override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247
    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_MKTG_SITE.
        """
        with patch.dict("django.conf.settings.FEATURES", {
            'ENTRANCE_EXAMS': feature_flags[0],
            'ENABLE_MKTG_SITE': feature_flags[1]
        }):
            course_details_url = get_url(self.course.id)
            resp = self.client.get_html(course_details_url)
            self.assertEqual(
                feature_flags[2],
                '<h3 id="heading-entrance-exam">' in resp.content
            )
248

249 250
    @override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
    def test_marketing_site_fetch(self):
251
        settings_details_url = get_url(self.course.id)
252

253 254 255 256 257
        with mock.patch.dict('django.conf.settings.FEATURES', {
            'ENABLE_MKTG_SITE': True,
            'ENTRANCE_EXAMS': False,
            'ENABLE_PREREQUISITE_COURSES': False
        }):
Don Mitchell committed
258
            response = self.client.get_html(settings_details_url)
259 260
            self.assertNotContains(response, "Course Summary Page")
            self.assertNotContains(response, "Send a note to students via email")
cahrens committed
261
            self.assertContains(response, "course summary page will not be viewable")
262 263 264

            self.assertContains(response, "Course Start Date")
            self.assertContains(response, "Course End Date")
265 266
            self.assertContains(response, "Enrollment Start Date")
            self.assertContains(response, "Enrollment End Date")
267 268
            self.assertContains(response, "not the dates shown on your course summary page")

269
            self.assertContains(response, "Introducing Your Course")
270
            self.assertContains(response, "Course Card Image")
271
            self.assertContains(response, "Course Short Description")
272 273 274 275
            self.assertNotContains(response, "Course Title")
            self.assertNotContains(response, "Course Subtitle")
            self.assertNotContains(response, "Course Duration")
            self.assertNotContains(response, "Course Description")
276 277
            self.assertNotContains(response, "Course Overview")
            self.assertNotContains(response, "Course Introduction Video")
278
            self.assertNotContains(response, "Requirements")
279 280
            self.assertNotContains(response, "Course Banner Image")
            self.assertNotContains(response, "Course Video Thumbnail Image")
281

282
    @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True)
283
    def test_entrance_exam_created_updated_and_deleted_successfully(self):
Ahmed Jazzar committed
284 285 286 287 288 289 290 291
        """
        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')

292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308
        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': ''
        }
        response = self.client.post(settings_details_url, data=json.dumps(data), content_type='application/json',
                                    HTTP_ACCEPT='application/json')
        self.assertEquals(response.status_code, 200)
        course = modulestore().get_course(self.course.id)
        self.assertTrue(course.entrance_exam_enabled)
        self.assertEquals(course.entrance_exam_minimum_score_pct, .60)

309 310 311 312 313 314 315 316 317 318 319 320 321 322
        # 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.assertEquals(response.status_code, 200)
        course = modulestore().get_course(self.course.id)
        self.assertTrue(course.entrance_exam_enabled)
        self.assertEquals(course.entrance_exam_minimum_score_pct, .80)

Ahmed Jazzar committed
323 324 325
        self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
                        msg='The entrance exam should be required.')

326 327 328 329 330 331 332 333 334 335 336 337 338
        # 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.assertEquals(response.status_code, 200)
        self.assertFalse(course.entrance_exam_enabled)
        self.assertEquals(course.entrance_exam_minimum_score_pct, None)

Ahmed Jazzar committed
339 340 341
        self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
                         msg='The entrance exam should not be required anymore')

342
    @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True)
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391
    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': ''
        }
        response = self.client.post(
            settings_details_url,
            data=json.dumps(test_data_1),
            content_type='application/json',
            HTTP_ACCEPT='application/json'
        )
        self.assertEquals(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.assertEquals(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': ''
        }

        response = self.client.post(
            settings_details_url,
            data=json.dumps(test_data_2),
            content_type='application/json',
            HTTP_ACCEPT='application/json'
        )
        self.assertEquals(response.status_code, 200)
        course = modulestore().get_course(self.course.id)
        self.assertTrue(course.entrance_exam_enabled)
        self.assertEquals(course.entrance_exam_minimum_score_pct, .5)

392
    def test_editable_short_description_fetch(self):
393
        settings_details_url = get_url(self.course.id)
394 395 396 397 398

        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")

399
    def test_regular_site_fetch(self):
400
        settings_details_url = get_url(self.course.id)
401

402 403
        with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': False,
                                                               'ENABLE_EXTENDED_COURSE_DETAILS': True}):
Don Mitchell committed
404
            response = self.client.get_html(settings_details_url)
405
            self.assertContains(response, "Course Summary Page")
406
            self.assertContains(response, "Send a note to students via email")
cahrens committed
407
            self.assertNotContains(response, "course summary page will not be viewable")
408 409 410 411 412 413 414 415

            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.assertNotContains(response, "not the dates shown on your course summary page")

            self.assertContains(response, "Introducing Your Course")
416
            self.assertContains(response, "Course Card Image")
417 418 419 420
            self.assertContains(response, "Course Title")
            self.assertContains(response, "Course Subtitle")
            self.assertContains(response, "Course Duration")
            self.assertContains(response, "Course Description")
421
            self.assertContains(response, "Course Short Description")
422 423
            self.assertContains(response, "Course Overview")
            self.assertContains(response, "Course Introduction Video")
424
            self.assertContains(response, "Requirements")
425 426
            self.assertContains(response, "Course Banner Image")
            self.assertContains(response, "Course Video Thumbnail Image")
427

Calen Pennington committed
428

429
@ddt.ddt
430
class CourseGradingTest(CourseTestCase):
cahrens committed
431 432 433
    """
    Tests for the course settings grading page.
    """
434
    def test_initial_grader(self):
Don Mitchell committed
435 436 437
        test_grader = CourseGradingModel(self.course)
        self.assertIsNotNone(test_grader.graders)
        self.assertIsNotNone(test_grader.grade_cutoffs)
438 439

    def test_fetch_grader(self):
440
        test_grader = CourseGradingModel.fetch(self.course.id)
441 442
        self.assertIsNotNone(test_grader.graders, "No graders")
        self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
Calen Pennington committed
443

444
        for i, grader in enumerate(test_grader.graders):
445
            subgrader = CourseGradingModel.fetch_grader(self.course.id, i)
446
            self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal")
Calen Pennington committed
447

448 449
    @mock.patch('track.event_transaction_utils.uuid4')
    @mock.patch('models.settings.course_grading.tracker')
450
    @mock.patch('contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
451
    @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
452 453
    def test_update_from_json(self, store, send_signal, tracker, uuid):
        uuid.return_value = "mockUUID"
454
        self.course = CourseFactory.create(default_store=store)
455 456
        test_grader = CourseGradingModel.fetch(self.course.id)
        altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
457
        self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
458
        grading_policy_1 = self._grading_policy_hash_for_course()
459
        test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2
460
        altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
461
        self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2")
462
        grading_policy_2 = self._grading_policy_hash_for_course()
463 464 465 466 467 468 469 470 471 472 473
        # 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__)
474
        grading_policy_3 = self._grading_policy_hash_for_course()
475
        test_grader.grade_cutoffs['D'] = 0.3
476
        altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
477
        self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
478
        grading_policy_4 = self._grading_policy_hash_for_course()
479
        test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
480
        altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user)
481
        self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
Calen Pennington committed
482

483 484
        # one for each of the calls to update_from_json()
        send_signal.assert_has_calls([
485 486 487 488 489
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
490 491
        ])

492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510
        # 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': unicode(self.course.id),
                    'event_transaction_type': 'edx.grades.grading_policy_changed',
                    'grading_policy_hash': policy_hash,
                    'user_id': unicode(self.user.id),
                    'event_transaction_id': 'mockUUID',
                }
            ) for policy_hash in (
                grading_policy_1, grading_policy_2, grading_policy_3, grading_policy_4, grading_policy_4
            )
        ])

    @mock.patch('track.event_transaction_utils.uuid4')
    @mock.patch('models.settings.course_grading.tracker')
511
    @mock.patch('contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
512 513
    def test_update_grader_from_json(self, send_signal, tracker, uuid):
        uuid.return_value = 'mockUUID'
514
        test_grader = CourseGradingModel.fetch(self.course.id)
515
        altered_grader = CourseGradingModel.update_grader_from_json(
516
            self.course.id, test_grader.graders[1], self.user
517
        )
518
        self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
519
        grading_policy_1 = self._grading_policy_hash_for_course()
Calen Pennington committed
520

521
        test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2
522
        altered_grader = CourseGradingModel.update_grader_from_json(
523
            self.course.id, test_grader.graders[1], self.user)
524
        self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2")
525
        grading_policy_2 = self._grading_policy_hash_for_course()
Calen Pennington committed
526

527
        test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
528
        altered_grader = CourseGradingModel.update_grader_from_json(
529
            self.course.id, test_grader.graders[1], self.user)
530
        self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
531
        grading_policy_3 = self._grading_policy_hash_for_course()
532

533 534
        # one for each of the calls to update_grader_from_json()
        send_signal.assert_has_calls([
535 536 537
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
538 539
        ])

540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557
        # 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': unicode(self.course.id),
                    'event_transaction_type': 'edx.grades.grading_policy_changed',
                    'grading_policy_hash': policy_hash,
                    'user_id': unicode(self.user.id),
                    'event_transaction_id': 'mockUUID',
                }
            ) for policy_hash in {grading_policy_1, grading_policy_2, grading_policy_3}
        ])

    @mock.patch('track.event_transaction_utils.uuid4')
    @mock.patch('models.settings.course_grading.tracker')
    def test_update_cutoffs_from_json(self, tracker, uuid):
        uuid.return_value = 'mockUUID'
558 559
        test_grader = CourseGradingModel.fetch(self.course.id)
        CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user)
560 561
        # 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.
562
        altered_grader = CourseGradingModel.fetch(self.course.id)
563
        self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
564
        grading_policy_1 = self._grading_policy_hash_for_course()
565 566

        test_grader.grade_cutoffs['D'] = 0.3
567 568
        CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user)
        altered_grader = CourseGradingModel.fetch(self.course.id)
569
        self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
570
        grading_policy_2 = self._grading_policy_hash_for_course()
571 572

        test_grader.grade_cutoffs['Pass'] = 0.75
573 574
        CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user)
        altered_grader = CourseGradingModel.fetch(self.course.id)
575
        self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
576 577 578 579 580 581 582 583 584 585 586 587 588 589 590
        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': unicode(self.course.id),
                    'event_transaction_type': 'edx.grades.grading_policy_changed',
                    'grading_policy_hash': policy_hash,
                    'user_id': unicode(self.user.id),
                    'event_transaction_id': 'mockUUID',
                }
            ) for policy_hash in (grading_policy_1, grading_policy_2, grading_policy_3)
        ])
591 592

    def test_delete_grace_period(self):
593
        test_grader = CourseGradingModel.fetch(self.course.id)
594
        CourseGradingModel.update_grace_period_from_json(
595
            self.course.id, test_grader.grace_period, self.user
596
        )
597
        # update_grace_period_from_json doesn't return anything, so query the db for its contents.
598
        altered_grader = CourseGradingModel.fetch(self.course.id)
599 600 601
        self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")

        test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
602
        CourseGradingModel.update_grace_period_from_json(
603 604
            self.course.id, test_grader.grace_period, self.user)
        altered_grader = CourseGradingModel.fetch(self.course.id)
605 606 607 608
        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
609
        CourseGradingModel.delete_grace_period(self.course.id, self.user)
610
        # update_grace_period_from_json doesn't return anything, so query the db for its contents.
611
        altered_grader = CourseGradingModel.fetch(self.course.id)
612 613 614
        # Once deleted, the grace period should simply be None
        self.assertEqual(None, altered_grader.grace_period, "Delete grace period")

615 616
    @mock.patch('track.event_transaction_utils.uuid4')
    @mock.patch('models.settings.course_grading.tracker')
617
    @mock.patch('contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
618 619
    def test_update_section_grader_type(self, send_signal, tracker, uuid):
        uuid.return_value = 'mockUUID'
620
        # Get the descriptor and the section_grader_type and assert they are the default values
621
        descriptor = modulestore().get_item(self.course.location)
622
        section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
623

624
        self.assertEqual('notgraded', section_grader_type['graderType'])
Calen Pennington committed
625 626
        self.assertEqual(None, descriptor.format)
        self.assertEqual(False, descriptor.graded)
627 628

        # Change the default grader type to Homework, which should also mark the section as graded
629
        CourseGradingModel.update_section_grader_type(self.course, 'Homework', self.user)
630
        descriptor = modulestore().get_item(self.course.location)
631
        section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
632
        grading_policy_1 = self._grading_policy_hash_for_course()
633 634

        self.assertEqual('Homework', section_grader_type['graderType'])
Calen Pennington committed
635 636
        self.assertEqual('Homework', descriptor.format)
        self.assertEqual(True, descriptor.graded)
637

638
        # Change the grader type back to notgraded, which should also unmark the section as graded
639
        CourseGradingModel.update_section_grader_type(self.course, 'notgraded', self.user)
640
        descriptor = modulestore().get_item(self.course.location)
641
        section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
642
        grading_policy_2 = self._grading_policy_hash_for_course()
643

644
        self.assertEqual('notgraded', section_grader_type['graderType'])
Calen Pennington committed
645 646
        self.assertEqual(None, descriptor.format)
        self.assertEqual(False, descriptor.graded)
647

648 649
        # one for each call to update_section_grader_type()
        send_signal.assert_has_calls([
650 651
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
652 653
        ])

654 655 656 657 658 659 660 661 662 663 664 665 666
        tracker.emit.assert_has_calls([
            mock.call(
                GRADING_POLICY_CHANGED_EVENT_TYPE,
                {
                    'course_id': unicode(self.course.id),
                    'event_transaction_type': 'edx.grades.grading_policy_changed',
                    'grading_policy_hash': policy_hash,
                    'user_id': unicode(self.user.id),
                    'event_transaction_id': 'mockUUID',
                }
            ) for policy_hash in (grading_policy_1, grading_policy_2)
        ])

667 668 669 670
    def _model_from_url(self, url_base):
        response = self.client.get_json(url_base)
        return json.loads(response.content)

Don Mitchell committed
671 672
    def test_get_set_grader_types_ajax(self):
        """
673
        Test creating and fetching the graders via ajax calls.
Don Mitchell committed
674
        """
675
        grader_type_url_base = get_url(self.course.id, 'grading_handler')
676 677
        whole_model = self._model_from_url(grader_type_url_base)

Don Mitchell committed
678 679 680
        self.assertIn('graders', whole_model)
        self.assertIn('grade_cutoffs', whole_model)
        self.assertIn('grace_period', whole_model)
681

Don Mitchell committed
682 683 684 685
        # 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)
686
        whole_model = self._model_from_url(grader_type_url_base)
Don Mitchell committed
687
        self.assertEqual(whole_model['grace_period'], {'hours': 1, 'minutes': 30, 'seconds': 0})
688

Don Mitchell committed
689 690
        # test get one grader
        self.assertGreater(len(whole_model['graders']), 1)  # ensure test will make sense
691
        grader_sample = self._model_from_url(grader_type_url_base + '/1')
Don Mitchell committed
692
        self.assertEqual(grader_sample, whole_model['graders'][1])
693 694 695 696 697 698

    @mock.patch('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)

Don Mitchell committed
699 700 701 702 703 704 705 706
        # test add grader
        new_grader = {
            "type": "Extra Credit",
            "min_count": 1,
            "drop_count": 2,
            "short_label": None,
            "weight": 15,
        }
707

Don Mitchell committed
708
        response = self.client.ajax_post(
709
            '{}/{}'.format(grader_type_url_base, len(original_model['graders'])),
Don Mitchell committed
710 711
            new_grader
        )
712

Don Mitchell committed
713 714
        self.assertEqual(200, response.status_code)
        grader_sample = json.loads(response.content)
715
        new_grader['id'] = len(original_model['graders'])
Don Mitchell committed
716
        self.assertEqual(new_grader, grader_sample)
717 718

        # test deleting the original grader
Don Mitchell committed
719
        response = self.client.delete(grader_type_url_base + '/1', HTTP_ACCEPT="application/json")
720

Don Mitchell committed
721
        self.assertEqual(204, response.status_code)
722
        updated_model = self._model_from_url(grader_type_url_base)
Don Mitchell committed
723 724
        new_grader['id'] -= 1  # one fewer and the id mutates
        self.assertIn(new_grader, updated_model['graders'])
725 726 727
        self.assertNotIn(original_model['graders'][1], updated_model['graders'])
        send_signal.assert_has_calls([
            # once for the POST
728
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
729
            # once for the DELETE
730
            mock.call(sender=CourseGradingModel, user_id=self.user.id, course_key=self.course.id),
731
        ])
Don Mitchell committed
732 733 734 735 736

    def setup_test_set_get_section_grader_ajax(self):
        """
        Populate the course, grab a section, get the url for the assignment type access
        """
737
        self.populate_course()
738
        sections = modulestore().get_items(self.course.id, qualifiers={'category': "sequential"})
Don Mitchell committed
739 740 741
        # see if test makes sense
        self.assertGreater(len(sections), 0, "No sections found")
        section = sections[0]  # just take the first one
742
        return reverse_usage_url('xblock_handler', section.location)
Don Mitchell committed
743 744 745 746 747 748 749 750 751 752 753

    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': u'Homework'})
        self.assertEqual(200, response.status_code)
        response = self.client.get_json(grade_type_url + '?fields=graderType')
        self.assertEqual(json.loads(response.content).get('graderType'), u'Homework')
        # and unset
754
        response = self.client.ajax_post(grade_type_url, {'graderType': u'notgraded'})
Don Mitchell committed
755 756
        self.assertEqual(200, response.status_code)
        response = self.client.get_json(grade_type_url + '?fields=graderType')
757
        self.assertEqual(json.loads(response.content).get('graderType'), u'notgraded')
Don Mitchell committed
758

759 760 761
    def _grading_policy_hash_for_course(self):
        return hash_grading_policy(modulestore().get_course(self.course.id).grading_policy)

762

763
@ddt.ddt
764
class CourseMetadataEditingTest(CourseTestCase):
cahrens committed
765 766 767
    """
    Tests for CourseMetadata.
    """
768 769
    def setUp(self):
        CourseTestCase.setUp(self)
Don Mitchell committed
770
        self.fullcourse = CourseFactory.create()
771 772
        self.course_setting_url = get_url(self.course.id, 'advanced_settings_handler')
        self.fullcourse_setting_url = get_url(self.fullcourse.id, 'advanced_settings_handler')
773
        self.notes_tab = {"type": "notes", "name": "My Notes"}
774 775

    def test_fetch_initial_fields(self):
776
        test_model = CourseMetadata.fetch(self.course)
777
        self.assertIn('display_name', test_model, 'Missing editable metadata field')
Don Mitchell committed
778
        self.assertEqual(test_model['display_name']['value'], self.course.display_name)
779

780
        test_model = CourseMetadata.fetch(self.fullcourse)
781 782
        self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
        self.assertIn('display_name', test_model, 'full missing editable metadata field')
Don Mitchell committed
783
        self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name)
784
        self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
785 786
        self.assertIn('showanswer', test_model, 'showanswer field ')
        self.assertIn('xqa_key', test_model, 'xqa_key field ')
787

Oleg Marshev committed
788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809
    @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)

    @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
810
        is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
Oleg Marshev committed
811 812 813 814 815 816 817 818 819 820 821 822 823 824
            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
825
        is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
Oleg Marshev committed
826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861
            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)

862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883
    @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
884
        is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
885 886 887 888 889 890 891 892 893 894 895 896 897 898
            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
899
        is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935
            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)

936 937 938 939 940 941 942 943 944
    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))

945
    def test_validate_from_json_correct_inputs(self):
946
        is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
947 948 949 950
            self.course,
            {
                "advertised_start": {"value": "start A"},
                "days_early_for_beta": {"value": 2},
951
                "advanced_modules": {"value": ['notes']},
952 953 954 955
            },
            user=self.user
        )
        self.assertTrue(is_valid)
956
        self.assertEqual(len(errors), 0)
957 958 959 960
        self.update_check(test_model)

        # Tab gets tested in test_advanced_settings_munge_tabs
        self.assertIn('advanced_modules', test_model, 'Missing advanced_modules')
961
        self.assertEqual(test_model['advanced_modules']['value'], ['notes'], 'advanced_module is not updated')
962

963
    def test_validate_from_json_wrong_inputs(self):
964
        # input incorrectly formatted data
965
        is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json(
966 967 968 969 970 971 972 973 974 975
            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
        )

976
        # Check valid results from validate_and_update_from_json
977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994
        self.assertFalse(is_valid)
        self.assertEqual(len(errors), 3)
        self.assertFalse(test_model)

        error_keys = set([error_obj['model']['display_name'] for error_obj in errors])
        test_keys = set(['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({
995 996 997 998 999 1000 1001
            "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", },
        })
1002 1003 1004
        response = self.client.ajax_post(self.course_setting_url, json_data)
        self.assertEqual(400, response.status_code)

1005
    def test_update_from_json(self):
1006
        test_model = CourseMetadata.update_from_json(
1007 1008
            self.course,
            {
1009 1010
                "advertised_start": {"value": "start A"},
                "days_early_for_beta": {"value": 2},
1011 1012
            },
            user=self.user
1013
        )
1014
        self.update_check(test_model)
1015
        # try fresh fetch to ensure persistence
1016
        fresh = modulestore().get_course(self.course.id)
1017
        test_model = CourseMetadata.fetch(fresh)
1018
        self.update_check(test_model)
1019
        # now change some of the existing metadata
1020
        test_model = CourseMetadata.update_from_json(
1021 1022
            fresh,
            {
1023 1024
                "advertised_start": {"value": "start B"},
                "display_name": {"value": "jolly roger"},
1025 1026
            },
            user=self.user
1027
        )
1028
        self.assertIn('display_name', test_model, 'Missing editable metadata field')
1029
        self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value")
1030
        self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
1031
        self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value")
1032

1033
    def update_check(self, test_model):
1034 1035 1036
        """
        checks that updates were made
        """
1037
        self.assertIn('display_name', test_model, 'Missing editable metadata field')
Don Mitchell committed
1038
        self.assertEqual(test_model['display_name']['value'], self.course.display_name)
1039
        self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
1040
        self.assertEqual(test_model['advertised_start']['value'], 'start A', "advertised_start not expected value")
1041
        self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
1042
        self.assertEqual(test_model['days_early_for_beta']['value'], 2, "days_early_for_beta not expected value")
1043

1044 1045 1046 1047
    def test_http_fetch_initial_fields(self):
        response = self.client.get_json(self.course_setting_url)
        test_model = json.loads(response.content)
        self.assertIn('display_name', test_model, 'Missing editable metadata field')
Don Mitchell committed
1048
        self.assertEqual(test_model['display_name']['value'], self.course.display_name)
1049 1050 1051 1052 1053

        response = self.client.get_json(self.fullcourse_setting_url)
        test_model = json.loads(response.content)
        self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
        self.assertIn('display_name', test_model, 'full missing editable metadata field')
Don Mitchell committed
1054
        self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name)
1055 1056 1057 1058 1059 1060
        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, {
1061 1062
            "advertised_start": {"value": "start A"},
            "days_early_for_beta": {"value": 2},
1063 1064 1065 1066 1067 1068 1069 1070 1071
        })
        test_model = json.loads(response.content)
        self.update_check(test_model)

        response = self.client.get_json(self.course_setting_url)
        test_model = json.loads(response.content)
        self.update_check(test_model)
        # now change some of the existing metadata
        response = self.client.ajax_post(self.course_setting_url, {
1072 1073
            "advertised_start": {"value": "start B"},
            "display_name": {"value": "jolly roger"}
1074 1075 1076
        })
        test_model = json.loads(response.content)
        self.assertIn('display_name', test_model, 'Missing editable metadata field')
1077
        self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value")
1078
        self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
1079
        self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value")
1080 1081 1082 1083 1084

    def test_advanced_components_munge_tabs(self):
        """
        Test that adding and removing specific advanced components adds and removes tabs.
        """
1085
        # First ensure that none of the tabs are visible
1086
        self.assertNotIn(self.notes_tab, self.course.tabs)
1087

1088
        # Now enable student notes and verify that the "My Notes" tab has been added
1089
        self.client.ajax_post(self.course_setting_url, {
1090
            'advanced_modules': {"value": ["notes"]}
1091 1092
        })
        course = modulestore().get_course(self.course.id)
1093
        self.assertIn(self.notes_tab, course.tabs)
1094

1095
        # Disable student notes and verify that the "My Notes" tab is gone
1096
        self.client.ajax_post(self.course_setting_url, {
1097
            'advanced_modules': {"value": [""]}
1098
        })
1099
        course = modulestore().get_course(self.course.id)
1100 1101 1102 1103 1104
        self.assertNotIn(self.notes_tab, course.tabs)

    def test_advanced_components_munge_tabs_validation_failure(self):
        with patch('contentstore.views.course._refresh_course_tabs', side_effect=InvalidTabsException):
            resp = self.client.ajax_post(self.course_setting_url, {
1105
                'advanced_modules': {"value": ["notes"]}
1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121
            })
            self.assertEqual(resp.status_code, 400)

            error_msg = [
                {
                    'message': 'An error occurred while trying to save your tabs',
                    'model': {'display_name': 'Tabs Exception'}
                }
            ]
            self.assertEqual(json.loads(resp.content), error_msg)

            # verify that the course wasn't saved into the modulestore
            course = modulestore().get_course(self.course.id)
            self.assertNotIn("notes", course.advanced_modules)

    @ddt.data(
1122 1123
        [{'type': 'course_info'}, {'type': 'courseware'}, {'type': 'wiki', 'is_hidden': True}],
        [{'type': 'course_info', 'name': 'Home'}, {'type': 'courseware', 'name': 'Course'}],
1124 1125 1126
    )
    def test_course_tab_configurations(self, tab_list):
        self.course.tabs = tab_list
1127 1128
        modulestore().update_item(self.course, self.user.id)
        self.client.ajax_post(self.course_setting_url, {
1129
            'advanced_modules': {"value": ["notes"]}
1130 1131
        })
        course = modulestore().get_course(self.course.id)
1132 1133
        tab_list.append(self.notes_tab)
        self.assertEqual(tab_list, course.tabs)
1134

1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151
    @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True})
    @patch('xmodule.util.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)

1152 1153

class CourseGraderUpdatesTest(CourseTestCase):
Don Mitchell committed
1154 1155 1156
    """
    Test getting, deleting, adding, & updating graders
    """
1157
    def setUp(self):
Don Mitchell committed
1158
        """Compute the url to use in tests"""
1159
        super(CourseGraderUpdatesTest, self).setUp()
1160
        self.url = get_url(self.course.id, 'grading_handler')
Don Mitchell committed
1161
        self.starting_graders = CourseGradingModel(self.course).graders
1162 1163

    def test_get(self):
Don Mitchell committed
1164 1165
        """Test getting a specific grading type record."""
        resp = self.client.get_json(self.url + '/0')
1166
        self.assertEqual(resp.status_code, 200)
1167
        obj = json.loads(resp.content)
Don Mitchell committed
1168
        self.assertEqual(self.starting_graders[0], obj)
1169 1170

    def test_delete(self):
Don Mitchell committed
1171 1172
        """Test deleting a specific grading type record."""
        resp = self.client.delete(self.url + '/0', HTTP_ACCEPT="application/json")
1173
        self.assertEqual(resp.status_code, 204)
1174
        current_graders = CourseGradingModel.fetch(self.course.id).graders
Don Mitchell committed
1175 1176
        self.assertNotIn(self.starting_graders[0], current_graders)
        self.assertEqual(len(self.starting_graders) - 1, len(current_graders))
1177

Don Mitchell committed
1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191
    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)
        self.assertEqual(obj, grader)
1192
        current_graders = CourseGradingModel.fetch(self.course.id).graders
Don Mitchell committed
1193 1194 1195 1196 1197 1198 1199
        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.
1200 1201 1202 1203 1204 1205 1206
        grader = {
            "type": "manual",
            "min_count": 5,
            "drop_count": 10,
            "short_label": "yo momma",
            "weight": 17.3,
        }
Don Mitchell committed
1207
        resp = self.client.ajax_post('{}/{}'.format(self.url, len(self.starting_graders) + 1), grader)
1208
        self.assertEqual(resp.status_code, 200)
1209
        obj = json.loads(resp.content)
Don Mitchell committed
1210 1211 1212
        self.assertEqual(obj['id'], len(self.starting_graders))
        del obj['id']
        self.assertEqual(obj, grader)
1213
        current_graders = CourseGradingModel.fetch(self.course.id).graders
Don Mitchell committed
1214
        self.assertEqual(len(self.starting_graders) + 1, len(current_graders))
1215 1216 1217 1218 1219 1220 1221


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.
    """
1222
    NOT_EDITABLE_HELPER_MESSAGE = "Contact your edX partner manager to update these settings."
1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252
    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):
1253 1254 1255
        """
        Initialize course used to test enrollment fields.
        """
1256 1257 1258 1259 1260
        super(CourseEnrollmentEndFieldTest, self).setUp()
        self.course = CourseFactory.create(org='edX', number='dummy', display_name='Marketing Site Course')
        self.course_details_url = reverse_course_url('settings_handler', unicode(self.course.id))

    def _get_course_details_response(self, global_staff):
1261 1262 1263
        """
        Return the course details page as either global or non-global staff
        """
1264 1265 1266 1267 1268 1269 1270 1271
        user = UserFactory(is_staff=global_staff)
        CourseInstructorRole(self.course.id).add_users(user)

        self.client.login(username=user.username, password='test')

        return self.client.get_html(self.course_details_url)

    def _verify_editable(self, response):
1272 1273
        """
        Verify that the response has expected editable fields.
1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285

        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):
1286 1287
        """
        Verify that the response has expected non-editable fields.
1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300

        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_MKTG_SITE': False})
    def test_course_details_with_disabled_setting_global_staff(self):
1301 1302
        """
        Test that user enrollment end date is editable in response.
1303 1304 1305 1306 1307 1308 1309 1310

        Feature flag 'ENABLE_MKTG_SITE' is not enabled.
        User is global staff.
        """
        self._verify_editable(self._get_course_details_response(True))

    @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_MKTG_SITE': False})
    def test_course_details_with_disabled_setting_non_global_staff(self):
1311 1312
        """
        Test that user enrollment end date is editable in response.
1313 1314 1315 1316 1317 1318 1319

        Feature flag 'ENABLE_MKTG_SITE' 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_MKTG_SITE': True})
1320
    @override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
1321
    def test_course_details_with_enabled_setting_global_staff(self):
1322 1323
        """
        Test that user enrollment end date is editable in response.
1324 1325 1326 1327 1328 1329 1330

        Feature flag 'ENABLE_MKTG_SITE' is enabled.
        User is global staff.
        """
        self._verify_editable(self._get_course_details_response(True))

    @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_MKTG_SITE': True})
1331
    @override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
1332
    def test_course_details_with_enabled_setting_non_global_staff(self):
1333 1334
        """
        Test that user enrollment end date is not editable in response.
1335 1336 1337 1338 1339

        Feature flag 'ENABLE_MKTG_SITE' is enabled.
        User is non-global staff.
        """
        self._verify_not_editable(self._get_course_details_response(False))