Ejemplo n.º 1
0
    def test_marketable_enrollable_course_runs_with_archived(self, marketable_enrollable_course_runs_with_archived):
        """ Verify the endpoint filters course runs to those that are marketable and
        enrollable, including archived course runs (with an end date in the past). """

        past = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=2)
        future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2)

        course_run = CourseRunFactory(enrollment_start=None, enrollment_end=future, course=self.course)
        SeatFactory(course_run=course_run)

        filtered_course_runs = [
            CourseRunFactory(enrollment_start=None, enrollment_end=None, course=self.course),
            CourseRunFactory(
                enrollment_start=past, enrollment_end=future, course=self.course
            ),
            CourseRunFactory(enrollment_start=future, course=self.course),
            CourseRunFactory(enrollment_end=past, course=self.course),
        ]

        url = reverse('api:v1:course-detail', kwargs={'key': self.course.key})
        url = '{}?marketable_enrollable_course_runs_with_archived={}'.format(
            url, marketable_enrollable_course_runs_with_archived
        )
        response = self.client.get(url)

        assert response.status_code == 200

        if marketable_enrollable_course_runs_with_archived:
            # Emulate prefetching behavior.
            for course_run in filtered_course_runs:
                course_run.delete()

        assert response.data == self.serialize_course(self.course)
    def test_courses(self):
        """
        Verify the endpoint returns the list of available courses contained in
        the catalog, and that courses appearing in the response always have at
        least one serialized run.
        """
        url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id})

        for state in self.states():
            Course.objects.all().delete()

            course_run = CourseRunFactory(course__title='ABC Test Course')
            for function in state:
                function(course_run)

            course_run.save()

            if state in self.available_states:
                course = course_run.course

                # This run has no seats, but we still expect its parent course
                # to be included.
                filtered_course_run = CourseRunFactory(course=course)

                with self.assertNumQueries(19):
                    response = self.client.get(url)

                assert response.status_code == 200

                # Emulate prefetching behavior.
                filtered_course_run.delete()

                assert response.data[
                    'results'] == self.serialize_catalog_course([course],
                                                                many=True)

                # Any course appearing in the response must have at least one serialized run.
                assert len(response.data['results'][0]['course_runs']) > 0
            else:
                with self.assertNumQueries(3):
                    response = self.client.get(url)

                assert response.status_code == 200
                assert response.data['results'] == []
Ejemplo n.º 3
0
    def test_get_include_published_course_run(self, published_course_runs_only):
        """
        Verify the endpoint returns hides unpublished programs if
        the 'published_course_runs_only' flag is set to True
        """
        CourseRunFactory(status=CourseRunStatus.Published, course=self.course)
        unpublished_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished, course=self.course)

        url = reverse('api:v1:course-detail', kwargs={'key': self.course.key})
        url = '{}?published_course_runs_only={}'.format(url, published_course_runs_only)

        response = self.client.get(url)

        assert response.status_code == 200

        if published_course_runs_only:
            # Emulate prefetching behavior.
            unpublished_course_run.delete()

        assert response.data == self.serialize_course(self.course)
Ejemplo n.º 4
0
class CourseRunViewSetTests(SerializationMixin, ElasticsearchTestMixin,
                            OAuth2Mixin, APITestCase):
    def setUp(self):
        super(CourseRunViewSetTests, self).setUp()
        self.user = UserFactory(is_staff=True)
        self.client.force_authenticate(self.user)
        self.course_run = CourseRunFactory(course__partner=self.partner)
        self.course_run_2 = CourseRunFactory(course__key='Test+Course',
                                             course__partner=self.partner)
        self.draft_course = CourseFactory(partner=self.partner, draft=True)
        self.draft_course_run = CourseRunFactory(course=self.draft_course,
                                                 draft=True)
        self.draft_course_run.course.authoring_organizations.add(
            OrganizationFactory(key='course-id'))
        self.refresh_index()
        self.request = APIRequestFactory().get('/')
        self.request.user = self.user

    def mock_patch_to_studio(self, key, access_token=True, status=200):
        if access_token:
            self.mock_access_token()
        studio_url = '{root}/api/v1/course_runs/{key}/'.format(
            root=self.partner.studio_url.strip('/'), key=key)
        responses.add(responses.PATCH, studio_url, status=status)
        responses.add(responses.POST,
                      '{url}images/'.format(url=studio_url),
                      status=status)

    def mock_post_to_studio(self, key, access_token=True):
        if access_token:
            self.mock_access_token()
        studio_url = '{root}/api/v1/course_runs/'.format(
            root=self.partner.studio_url.strip('/'))
        responses.add(responses.POST, studio_url, status=200)
        responses.add(responses.POST,
                      '{url}{key}/images/'.format(url=studio_url, key=key),
                      status=200)

    def test_get(self):
        """ Verify the endpoint returns the details for a single course. """
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.course_run.key})

        with self.assertNumQueries(11):
            response = self.client.get(url)

        assert response.status_code == 200
        self.assertEqual(response.data,
                         self.serialize_course_run(self.course_run))

    def test_get_exclude_deleted_programs(self):
        """ Verify the endpoint returns no associated deleted programs """
        ProgramFactory(courses=[self.course_run.course],
                       status=ProgramStatus.Deleted)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.course_run.key})

        with self.assertNumQueries(12):
            response = self.client.get(url)
        assert response.status_code == 200
        assert response.data.get('programs') == []

    def test_get_include_deleted_programs(self):
        """
        Verify the endpoint returns associated deleted programs
        with the 'include_deleted_programs' flag set to True
        """
        ProgramFactory(courses=[self.course_run.course],
                       status=ProgramStatus.Deleted)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.course_run.key})
        url += '?include_deleted_programs=1'

        with self.assertNumQueries(17):
            response = self.client.get(url)
        assert response.status_code == 200
        assert response.data == \
            self.serialize_course_run(self.course_run, extra_context={'include_deleted_programs': True})

    def test_get_exclude_unpublished_programs(self):
        """ Verify the endpoint returns no associated unpublished programs """
        ProgramFactory(courses=[self.course_run.course],
                       status=ProgramStatus.Unpublished)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.course_run.key})

        with self.assertNumQueries(12):
            response = self.client.get(url)
            assert response.status_code == 200
            assert response.data.get('programs') == []

    def test_get_include_unpublished_programs(self):
        """
        Verify the endpoint returns associated unpublished programs
        with the 'include_unpublished_programs' flag set to True
        """
        ProgramFactory(courses=[self.course_run.course],
                       status=ProgramStatus.Unpublished)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.course_run.key})
        url += '?include_unpublished_programs=1'

        with self.assertNumQueries(17):
            response = self.client.get(url)
        assert response.status_code == 200
        assert response.data == \
            self.serialize_course_run(self.course_run, extra_context={'include_unpublished_programs': True})

    @responses.activate
    def test_create_minimum(self):
        """ Verify the endpoint supports creating a course_run with the least info. """
        course = self.draft_course_run.course
        new_key = 'course-v1:{}+1T2000'.format(course.key.replace('/', '+'))
        self.mock_post_to_studio(new_key)
        url = reverse('api:v1:course_run-list')

        # Send nothing - expect complaints
        response = self.client.post(url, {}, format='json')
        self.assertEqual(response.status_code, 400)
        self.assertDictEqual(
            response.data, {
                'course': ['This field is required.'],
                'start': ['This field is required.'],
                'end': ['This field is required.'],
            })

        # Send minimum requested
        response = self.client.post(url, {
            'course': course.key,
            'start': '2000-01-01T00:00:00Z',
            'end': '2001-01-01T00:00:00Z',
        },
                                    format='json')
        self.assertEqual(response.status_code, 201)
        new_course_run = CourseRun.everything.get(key=new_key)
        self.assertDictEqual(response.data,
                             self.serialize_course_run(new_course_run))
        self.assertEqual(new_course_run.pacing_type,
                         'instructor_paced')  # default we provide
        self.assertEqual(
            str(new_course_run.end),
            '2001-01-01 00:00:00+00:00')  # spot check that input made it
        self.assertTrue(new_course_run.draft)

    @ddt.data(True, False, "bogus")
    @responses.activate
    def test_create_draft_ignored(self, draft):
        """ Verify the endpoint supports creating a course_run, but always as a draft. """
        course = self.draft_course_run.course
        new_key = 'course-v1:{}+1T2000'.format(course.key.replace('/', '+'))
        self.mock_post_to_studio(new_key)
        url = reverse('api:v1:course_run-list')

        # Send minimum + draft: True/False/bogus
        response = self.client.post(url, {
            'course': course.key,
            'start': '2000-01-01T00:00:00Z',
            'end': '2001-01-01T00:00:00Z',
            'draft': draft,
        },
                                    format='json')

        self.assertEqual(response.status_code, 201)
        new_course_run = CourseRun.everything.get(key=new_key)
        self.assertDictEqual(response.data,
                             self.serialize_course_run(new_course_run))
        self.assertTrue(new_course_run.draft)

    @responses.activate
    def test_create_with_key(self):
        """ Verify the endpoint supports creating a course_run when specifying a key (if allowed). """
        course = self.draft_course_run.course
        date_key = 'course-v1:{}+1T2000'.format(course.key.replace('/', '+'))
        desired_key = 'course-v1:{}+HowdyDoing'.format(
            course.key.replace('/', '+'))
        url = reverse('api:v1:course_run-list')

        data = {
            'course': course.key,
            'start': '2000-01-01T00:00:00Z',
            'end': '2001-01-01T00:00:00Z',
            'key': desired_key,
        }

        # If org doesn't specifically allow it, incoming key is ignored
        self.mock_post_to_studio(date_key)
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, 201)
        new_course_run = CourseRun.everything.get(key=date_key)
        self.assertDictEqual(response.data,
                             self.serialize_course_run(new_course_run))

        # Turn on this feature for this org, notice that we can now specify the course key we want
        org_ext = OrganizationExtensionFactory(
            organization=course.authoring_organizations.first())
        org_ext.auto_create_in_studio = False  # badly named, but this controls whether we let org name their keys
        org_ext.save()
        self.mock_post_to_studio(desired_key, access_token=False)
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, 201)
        new_course_run = CourseRun.everything.get(key=desired_key)
        self.assertDictEqual(response.data,
                             self.serialize_course_run(new_course_run))

    def test_create_if_in_org(self):
        """ Verify the endpoint supports creating a course_run with organization permissions. """
        url = reverse('api:v1:course_run-list')
        course = self.draft_course_run.course
        data = {'course': course.key}

        self.user.is_staff = False
        self.user.save()

        # Not in org, not allowed to POST
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, 403)

        # Add to org
        org_ext = OrganizationExtensionFactory(
            organization=course.authoring_organizations.first())
        self.user.groups.add(org_ext.group)

        # now allowed to POST
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code,
                         400)  # missing start, but at least we got that far

    def test_create_fails_with_missing_fields(self):
        course = self.draft_course_run.course
        new_key = 'course-v1:{}+1T2000'.format(course.key.replace('/', '+'))
        self.mock_post_to_studio(new_key)
        url = reverse('api:v1:course_run-list')

        # Send nothing - expect complaints
        response = self.client.post(url, {}, format='json')
        self.assertEqual(response.status_code, 400)
        self.assertDictEqual(
            response.data, {
                'course': ['This field is required.'],
                'start': ['This field is required.'],
                'end': ['This field is required.'],
            })

    @responses.activate
    def test_update_operates_on_drafts(self):
        self.assertFalse(
            CourseRun.everything.filter(key=self.course_run.key,
                                        draft=True).exists())  # sanity check
        self.mock_patch_to_studio(self.course_run.key)
        expected_original_max_effort = self.course_run.max_effort

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.course_run.key})
        response = self.client.patch(url, {'max_effort': 777}, format='json')
        self.assertEqual(response.status_code, 200)

        course_run = CourseRun.everything.get(key=self.course_run.key,
                                              draft=True)
        self.assertEqual(course_run.max_effort, 777)

        self.course_run.refresh_from_db()
        self.assertFalse(self.course_run.draft)
        self.assertEqual(self.course_run.max_effort,
                         expected_original_max_effort)

    @responses.activate
    def test_partial_update(self):
        """ Verify the endpoint supports partially updating a course_run's fields, provided user has permission. """
        self.mock_patch_to_studio(self.draft_course_run.key)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})

        expected_min_effort = 867
        expected_max_effort = 5309
        data = {
            'max_effort': expected_max_effort,
            'min_effort': expected_min_effort,
        }

        # Update this course_run with the new info
        response = self.client.patch(url, data, format='json')
        assert response.status_code == 200

        # refresh and make sure we have the new effort levels
        self.draft_course_run.refresh_from_db()

        assert self.draft_course_run.max_effort == expected_max_effort
        assert self.draft_course_run.min_effort == expected_min_effort

    def test_partial_update_no_studio_url(self):
        """ Verify we skip pushing when no studio url is set. """
        self.partner.studio_url = None
        self.partner.save()

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})

        with mock.patch(
                'course_discovery.apps.api.v1.views.course_runs.log.info'
        ) as mock_logger:
            response = self.client.patch(url, {}, format='json')

        self.assertEqual(response.status_code, 200)
        mock_logger.assert_called_with(
            'Not pushing course run info for %s to Studio as partner %s has no studio_url set.',
            self.draft_course_run.key,
            self.partner.short_code,
        )

    def test_partial_update_bad_permission(self):
        """ Verify partially updating will fail if user doesn't have permission. """
        user = UserFactory(is_staff=False, is_superuser=False)
        self.client.force_authenticate(user)
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})

        response = self.client.patch(url, {}, format='json')
        assert response.status_code == 404

    @ddt.data(
        (
            {
                'start': '2010-01-01T00:00:00Z',
                'end': '2000-01-01T00:00:00Z'
            },
            'Start date cannot be after the End date',
        ),
        (
            {
                'key': 'course-v1:Blarg+Hello+Run'
            },
            'Key cannot be changed',
        ),
        (
            {
                'course': 'Test+Course'
            },
            'Course cannot be changed',
        ),
        (
            {
                'min_effort': 10000
            },
            'Minimum effort cannot be greater than Maximum effort',
        ),
        (
            {
                'min_effort': 10000,
                'max_effort': 10000
            },
            'Minimum effort and Maximum effort cannot be the same',
        ),
        (
            {
                'max_effort': None
            },
            'Maximum effort cannot be empty',
        ),
    )
    @ddt.unpack
    def test_partial_update_common_errors(self, data, error):
        """ Verify partially updating will fail depending on various validation checks. """
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.patch(url, data, format='json')
        self.assertContains(response, error, status_code=400)

    def test_partial_update_staff(self):
        """ Verify partially updating allows staff updates. """
        self.mock_patch_to_studio(self.draft_course_run.key)

        p1 = PersonFactory()
        p2 = PersonFactory()
        PersonFactory()

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.patch(url, {'staff': [p2.uuid, p1.uuid]},
                                     format='json')
        self.assertEqual(response.status_code, 200)

        self.draft_course_run.refresh_from_db()
        self.assertListEqual(list(self.draft_course_run.staff.all()), [p2, p1])

    @responses.activate
    def test_partial_update_video(self):
        """ Verify partially updating allows video updates. """
        self.mock_patch_to_studio(self.draft_course_run.key)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.patch(
            url, {'video': {
                'src': 'https://example.com/blarg'
            }},
            format='json')
        self.assertEqual(response.status_code, 200)

        self.draft_course_run.refresh_from_db()
        self.assertEqual(self.draft_course_run.video.src,
                         'https://example.com/blarg')

    @responses.activate
    def test_update_if_editor(self):
        """ Verify the endpoint supports updating a course_run with editor permissions. """
        self.mock_patch_to_studio(self.draft_course_run.key)
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})

        self.user.is_staff = False
        self.user.save()

        # Not an editor, not allowed to patch
        response = self.client.patch(url, {}, format='json')
        self.assertEqual(response.status_code, 404)

        # Add as editor
        org_ext = OrganizationExtensionFactory(
            organization=self.draft_course_run.course.authoring_organizations.
            first())
        self.user.groups.add(org_ext.group)
        CourseEditorFactory(user=self.user,
                            course=self.draft_course_run.course)

        # now allowed to patch
        response = self.client.patch(url, {}, format='json')
        self.assertEqual(response.status_code, 200)

    @responses.activate
    def test_studio_update_failure(self):
        """ Verify we bubble up error correctly if studio is giving us static. """
        self.mock_patch_to_studio(self.draft_course_run.key, status=400)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.patch(url, {'title': 'New Title'},
                                     format='json')
        self.assertContains(response,
                            'Failed to set course run data: Client Error 400',
                            status_code=400)

        self.draft_course_run.refresh_from_db()
        self.assertEqual(self.draft_course_run.title_override,
                         None)  # prove we didn't touch the course run object

    @responses.activate
    def test_full_update(self):
        """ Verify full updating is allowed. """
        self.mock_patch_to_studio(self.draft_course_run.key)

        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.put(
            url,
            {
                'course': self.draft_course_run.course.
                key,  # required, so we need for a put
                'start':
                self.draft_course_run.start,  # required, so we need for a put
                'end':
                self.draft_course_run.end,  # required, so we need for a put
                'title': 'New Title',
            },
            format='json')
        self.assertEqual(response.status_code, 200)

        self.draft_course_run.refresh_from_db()
        self.assertEqual(self.draft_course_run.title_override, 'New Title')

    @ddt.data(
        CourseRunStatus.LegalReview,
        CourseRunStatus.InternalReview,
    )
    def test_patch_put_restrict_when_reviewing(self, status):
        self.draft_course_run.status = status
        self.draft_course_run.save()
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.put(
            url,
            {
                'course': self.draft_course_run.course.
                key,  # required, so we need for a put
                'start':
                self.draft_course_run.start,  # required, so we need for a put
                'end':
                self.draft_course_run.end,  # required, so we need for a put
            },
            format='json')
        assert response.status_code == 403

        response = self.client.patch(url, {}, format='json')
        assert response.status_code == 403

    @responses.activate
    def test_patch_put_reset_status(self):
        self.mock_patch_to_studio(self.draft_course_run.key)
        self.draft_course_run.status = CourseRunStatus.Reviewed
        self.draft_course_run.save()
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.put(
            url,
            {
                'course': self.draft_course_run.course.
                key,  # required, so we need for a put
                'start':
                self.draft_course_run.start,  # required, so we need for a put
                'end':
                self.draft_course_run.end,  # required, so we need for a put
                'status': 'reviewed',
            },
            format='json')
        assert response.status_code == 200
        self.draft_course_run.refresh_from_db()
        draft_course_run = CourseRun.everything.get(
            key=self.draft_course_run.key, draft=True)
        assert draft_course_run.status == CourseRunStatus.Unpublished

    @ddt.data(
        CourseRunStatus.Unpublished,
        CourseRunStatus.Reviewed,
    )
    @responses.activate
    def test_patch_put_draft_false(self, status):
        """ Verify that setting draft to False moves status to LegalReview. """
        self.mock_patch_to_studio(self.draft_course_run.key)
        self.draft_course_run.status = status
        self.draft_course_run.save()
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.draft_course_run.key})
        response = self.client.put(
            url,
            {
                'course': self.draft_course_run.course.
                key,  # required, so we need for a put
                'start':
                self.draft_course_run.start,  # required, so we need for a put
                'end':
                self.draft_course_run.end,  # required, so we need for a put
                'draft': False,
            },
            format='json')
        assert response.status_code == 200, "Status {}: {}".format(
            response.status_code, response.content)
        draft_course_run = CourseRun.everything.get(
            key=self.draft_course_run.key, draft=True)
        assert draft_course_run.status == CourseRunStatus.LegalReview

    def test_list(self):
        """ Verify the endpoint returns a list of all course runs. """
        url = reverse('api:v1:course_run-list')

        with self.assertNumQueries(13):
            response = self.client.get(url)

        assert response.status_code == 200
        self.assertListEqual(
            response.data['results'],
            self.serialize_course_run(CourseRun.objects.all().order_by(
                Lower('key')),
                                      many=True))

    def test_list_sorted_by_course_start_date(self):
        """ Verify the endpoint returns a list of all course runs sorted by start date. """
        url = '{root}?ordering=start'.format(
            root=reverse('api:v1:course_run-list'))

        with self.assertNumQueries(13):
            response = self.client.get(url)

        assert response.status_code == 200
        self.assertListEqual(
            response.data['results'],
            self.serialize_course_run(
                CourseRun.objects.all().order_by('start'), many=True))

    def test_list_query(self):
        """ Verify the endpoint returns a filtered list of courses """
        course_runs = CourseRunFactory.create_batch(
            3, title='Some random title', course__partner=self.partner)
        CourseRunFactory(title='non-matching name')
        query = 'title:Some random title'
        url = '{root}?q={query}'.format(root=reverse('api:v1:course_run-list'),
                                        query=query)

        with self.assertNumQueries(14):
            response = self.client.get(url)

        actual_sorted = sorted(response.data['results'],
                               key=lambda course_run: course_run['key'])
        expected_sorted = sorted(self.serialize_course_run(course_runs,
                                                           many=True),
                                 key=lambda course_run: course_run['key'])
        self.assertListEqual(actual_sorted, expected_sorted)

    def assert_list_results(self, url, expected, extra_context=None):
        expected = sorted(expected,
                          key=lambda course_run: course_run.key.lower())
        response = self.client.get(url)
        assert response.status_code == 200
        self.assertListEqual(
            response.data['results'],
            self.serialize_course_run(expected,
                                      many=True,
                                      extra_context=extra_context))

    def test_filter_by_keys(self):
        """ Verify the endpoint returns a list of course runs filtered by the specified keys. """
        CourseRun.objects.all().delete()
        expected = CourseRunFactory.create_batch(3,
                                                 course__partner=self.partner)
        keys = ','.join([course.key for course in expected])
        url = '{root}?keys={keys}'.format(
            root=reverse('api:v1:course_run-list'), keys=keys)
        self.assert_list_results(url, expected)

    def test_filter_by_marketable(self):
        """ Verify the endpoint filters course runs to those that are marketable. """
        CourseRun.objects.all().delete()
        expected = CourseRunFactory.create_batch(3,
                                                 course__partner=self.partner)
        for course_run in expected:
            SeatFactory(course_run=course_run)

        CourseRunFactory.create_batch(3,
                                      slug=None,
                                      course__partner=self.partner)
        CourseRunFactory.create_batch(3, slug='', course__partner=self.partner)

        url = reverse('api:v1:course_run-list') + '?marketable=1'
        self.assert_list_results(url, expected)

    def test_filter_by_hidden(self):
        """ Verify the endpoint filters course runs that are hidden. """
        CourseRun.objects.all().delete()
        course_runs = CourseRunFactory.create_batch(
            3, course__partner=self.partner)
        hidden_course_runs = CourseRunFactory.create_batch(
            3, hidden=True, course__partner=self.partner)
        url = reverse('api:v1:course_run-list')
        self.assert_list_results(url, course_runs + hidden_course_runs)
        url = reverse('api:v1:course_run-list') + '?hidden=False'
        self.assert_list_results(url, course_runs)

    def test_filter_by_active(self):
        """ Verify the endpoint filters course runs to those that are active. """
        CourseRun.objects.all().delete()

        # Create course with end date in future and enrollment_end in past.
        end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2)
        enrollment_end = datetime.datetime.now(
            pytz.UTC) - datetime.timedelta(days=1)
        CourseRunFactory(end=end,
                         enrollment_end=enrollment_end,
                         course__partner=self.partner)

        # Create course with end date in past and no enrollment_end.
        end = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=2)
        CourseRunFactory(end=end,
                         enrollment_end=None,
                         course__partner=self.partner)

        # Create course with end date in future and enrollment_end in future.
        end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2)
        enrollment_end = datetime.datetime.now(
            pytz.UTC) + datetime.timedelta(days=1)
        active_enrollment_end = CourseRunFactory(end=end,
                                                 enrollment_end=enrollment_end,
                                                 course__partner=self.partner)

        # Create course with end date in future and no enrollment_end.
        active_no_enrollment_end = CourseRunFactory(
            end=end, enrollment_end=None, course__partner=self.partner)

        expected = [active_enrollment_end, active_no_enrollment_end]
        url = reverse('api:v1:course_run-list') + '?active=1'
        self.assert_list_results(url, expected)

    def test_filter_by_license(self):
        CourseRun.objects.all().delete()
        course_runs_cc = CourseRunFactory.create_batch(
            3, course__partner=self.partner, license='cc-by-sa')
        CourseRunFactory.create_batch(3,
                                      course__partner=self.partner,
                                      license='')

        url = reverse('api:v1:course_run-list') + '?license=cc-by-sa'
        self.assert_list_results(url, course_runs_cc)

    def test_list_exclude_utm(self):
        """ Verify the endpoint returns marketing URLs without UTM parameters. """
        url = reverse('api:v1:course_run-list') + '?exclude_utm=1'
        self.assert_list_results(url,
                                 CourseRun.objects.all(),
                                 extra_context={'exclude_utm': 1})

    def test_contains_single_course_run(self):
        """ Verify that a single course_run is contained in a query """
        qs = urllib.parse.urlencode({
            'query': 'id:course*',
            'course_run_ids': self.course_run.key,
        })
        url = '{}?{}'.format(reverse('api:v1:course_run-contains'), qs)
        response = self.client.get(url)
        assert response.status_code == 200
        self.assertEqual(response.data,
                         {'course_runs': {
                             self.course_run.key: True
                         }})

    def test_contains_multiple_course_runs(self):
        qs = urllib.parse.urlencode({
            'query':
            'id:course*',
            'course_run_ids':
            '{},{},{}'.format(self.course_run.key, self.course_run_2.key,
                              'abc')
        })
        url = '{}?{}'.format(reverse('api:v1:course_run-contains'), qs)

        response = self.client.get(url)
        assert response.status_code == 200
        self.assertDictEqual(
            response.data, {
                'course_runs': {
                    self.course_run.key: True,
                    self.course_run_2.key: True,
                    'abc': False
                }
            })

    @ddt.data({'params': {
        'course_run_ids': 'a/b/c'
    }}, {'params': {
        'query': 'id:course*'
    }}, {'params': {}})
    @ddt.unpack
    def test_contains_missing_parameter(self, params):
        qs = urllib.parse.urlencode(params)
        url = '{}?{}'.format(reverse('api:v1:course_run-contains'), qs)

        response = self.client.get(url)
        assert response.status_code == 400

    def test_options(self):
        url = reverse('api:v1:course_run-detail',
                      kwargs={'key': self.course_run.key})
        response = self.client.options(url)
        self.assertEqual(response.status_code, 200)

        data = response.data['actions']['PUT']
        self.assertEqual(
            data['level_type']['choices'],
            [{
                'display_name': self.course_run.level_type.name,
                'value': self.course_run.level_type.name
            }, {
                'display_name': self.course_run_2.level_type.name,
                'value': self.course_run_2.level_type.name
            }, {
                'display_name': self.draft_course_run.level_type.name,
                'value': self.draft_course_run.level_type.name
            }])
        self.assertEqual(data['content_language']['choices'],
                         [{
                             'display_name': x.name,
                             'value': x.code
                         } for x in LanguageTag.objects.all()])
        self.assertTrue(LanguageTag.objects.count() > 0)

    def test_editable_list_gives_drafts(self):
        # We delete self.course_run_2 and self.draft_course_run here so we can test that specifically
        # draft and extra are the only ones showing up.
        self.course_run_2.delete()
        self.draft_course_run.delete()

        draft = CourseRunFactory(course__partner=self.partner,
                                 uuid=self.course_run.uuid,
                                 key=self.course_run.key,
                                 draft=True)
        self.course_run.draft_version = draft
        self.course_run.save()
        extra = CourseRunFactory(course__partner=self.partner)

        response = self.client.get(
            reverse('api:v1:course_run-list') + '?editable=1')
        actual_sorted = sorted(response.data['results'],
                               key=lambda course_run: course_run['key'])
        expected_sorted = sorted(self.serialize_course_run([draft, extra],
                                                           many=True),
                                 key=lambda course_run: course_run['key'])
        self.assertEqual(response.status_code, 200)
        self.assertEqual(actual_sorted, expected_sorted)

    def test_editable_get_gives_drafts(self):
        draft = CourseRunFactory(course__partner=self.partner,
                                 uuid=self.course_run.uuid,
                                 key=self.course_run.key,
                                 draft=True)
        self.course_run.draft_version = draft
        self.course_run.save()
        extra = CourseRunFactory(course__partner=self.partner)

        response = self.client.get(
            reverse('api:v1:course_run-detail',
                    kwargs={'key': self.course_run.key}) + '?editable=1')
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data,
                         self.serialize_course_run(draft, many=False))

        response = self.client.get(
            reverse('api:v1:course_run-detail', kwargs={'key': extra.key}) +
            '?editable=1')
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data,
                         self.serialize_course_run(extra, many=False))

    def test_list_query_with_editable_raises_exception(self):
        """ Verify the endpoint raises an exception if both a q param and editable=1 are passed in """
        query = 'title:Some random title'
        url = '{root}?q={query}&editable=1'.format(
            root=reverse('api:v1:course_run-list'), query=query)

        with pytest.raises(EditableAndQUnsupported) as exc:
            self.client.get(url)

        self.assertEqual(
            str(exc.value),
            'Specifying both editable=1 and a q parameter is not supported.')