Exemplo n.º 1
0
    def _define_course_metadata(self):
        # Add course runs, courses, and programs to DB to copy data into
        courses = {}
        # Course runs map to courses in the way opaque keys would (without actually using opaque keys code)
        course_to_run_mapping = {
            '00test/00test/00test': '00test/00test',
            '00test/00test/01test': '00test/00test',
            '00test/01test/00test': '00test/01test',
            '00test/01test/01test': '00test/01test',
            '00test/01test/02test': '00test/01test',
            '00test/02test/00test': '00test/02test'
        }
        for course_summary in self.mocked_data:
            course_run_key = course_summary['course_id']
            course_key = course_to_run_mapping[course_run_key]
            if course_key in courses:
                course = courses[course_key]
                course_run = CourseRunFactory(key=course_summary['course_id'],
                                              course=course)
                course_run.save()
            else:
                course = CourseFactory(key=course_key)
                course.save()
                course_run = CourseRunFactory(key=course_summary['course_id'],
                                              course=course)
                course_run.save()
                courses[course_key] = course_run.course

        # Create a program with all of the courses we created
        program = ProgramFactory()
        program.courses.set(courses.values())  # pylint: disable=no-member
class ImportCoursesTests(TestCase):
    def setUp(self):
        super(ImportCoursesTests, self).setUp()
        self.course = CourseFactory()
        self.course_runs = CourseRunFactory.create_batch(3, course=self.course)
        self.course.canonical_course_run = self.course_runs[2]
        self.course.save()

        # add multiple courses.
        self.course_2 = CourseFactory()

        self.command_name = 'import_metadata_courses'
        self.command_args = [
            '--start_id={}'.format(self.course.id),
            '--end_id={}'.format(self.course.id)
        ]

    @mock.patch(
        'course_discovery.apps.publisher.dataloader.create_courses.process_course'
    )
    def test_query_return_correct_course(self, process_course):
        """ Verify that query return correct courses using start and end ids. """
        call_command(self.command_name, *self.command_args)
        call_list = [
            mock.call(self.course),
        ]
        self.assertEqual(call_list, process_course.call_args_list)

    @mock.patch(
        'course_discovery.apps.publisher.dataloader.create_courses.process_course'
    )
    def test_query_return_correct_courses(self, process_course):
        """ Verify that query return correct courses using start and end ids. """
        course_3 = CourseFactory()
        call_command(
            self.command_name, *[
                '--start_id={}'.format(self.course_2.id),
                '--end_id={}'.format(course_3.id)
            ])
        call_list = [
            mock.call(self.course_2),
            mock.call(course_3),
        ]
        self.assertEqual(call_list, process_course.call_args_list)

    @mock.patch(
        'course_discovery.apps.publisher.dataloader.create_courses.create_or_update_course'
    )
    def test_course_without_auth_organization(self, create_or_update_course):
        """ Verify that if the course has no organization then that course will not be
        imported to publisher.
        """
        with LogCapture(dataloader_logger.name) as log_capture:
            call_command(self.command_name, *self.command_args)
            log_capture.check(
                (dataloader_logger.name, 'WARNING',
                 'Course has no organization. Course uuid is [{}].'.format(
                     self.course.uuid)))
            create_or_update_course.assert_not_called()
Exemplo n.º 3
0
    def create_discovery_course_with_partner(partner):
        """
        Creates and returns a Discovery Course object with a partner field.

        Arguments:
            partner: a Partner object to assign to the created Discovery Course.partner field

        Returns:
            a Discovery Course object
        """
        discovery_course = DiscoveryCourseFactory(partner=partner)
        discovery_course.save()
        return discovery_course
Exemplo n.º 4
0
class CatalogQueryViewSetTests(APITestCase):
    def setUp(self):
        super().setUp()
        self.user = UserFactory(is_staff=True, is_superuser=True)
        self.client.force_authenticate(self.user)
        self.course = CourseFactory(partner=self.partner, key='simple_key')
        self.course_run = CourseRunFactory(course=self.course)
        self.url_base = reverse('api:v1:catalog-query_contains')
        self.error_message = 'CatalogQueryContains endpoint requires query and identifiers list(s)'

    def test_contains_single_course_run(self):
        """ Verify that a single course_run is contained in a query. """
        qs = urllib.parse.urlencode({
            'query': 'id:' + self.course_run.key,
            'course_run_ids': self.course_run.key,
            'course_uuids': self.course.uuid,
        })
        url = f'{self.url_base}/?{qs}'
        response = self.client.get(url)
        assert response.status_code == 200
        assert response.data == {
            self.course_run.key: True,
            str(self.course.uuid): False
        }

    def test_contains_single_course(self):
        """ Verify that a single course is contained in a query. """
        qs = urllib.parse.urlencode({
            'query': 'key:' + self.course.key,
            'course_run_ids': self.course_run.key,
            'course_uuids': self.course.uuid,
        })
        url = f'{self.url_base}/?{qs}'
        response = self.client.get(url)
        assert response.status_code == 200
        assert response.data == {
            self.course_run.key: False,
            str(self.course.uuid): True
        }

    def test_contains_course_and_run(self):
        """ Verify that both the course and the run are contained in the broadest query. """
        self.course.course_runs.add(self.course_run)
        self.course.save()
        qs = urllib.parse.urlencode({
            'query': 'org:*',
            'course_run_ids': self.course_run.key,
            'course_uuids': self.course.uuid,
        })
        url = f'{self.url_base}/?{qs}'
        response = self.client.get(url)
        assert response.status_code == 200
        assert response.data == {
            self.course_run.key: True,
            str(self.course.uuid): True
        }

    def test_no_identifiers(self):
        """ Verify that a 400 status is returned if request does not contain any identifier lists. """
        qs = urllib.parse.urlencode({'query': 'id:*'})
        url = f'{self.url_base}/?{qs}'
        response = self.client.get(url)
        assert response.status_code == 400
        assert response.data == self.error_message

    def test_no_query(self):
        """ Verify that a 400 status is returned if request does not contain a querystring. """
        qs = urllib.parse.urlencode({
            'course_run_ids': self.course_run.key,
            'course_uuids': self.course.uuid,
        })
        url = f'{self.url_base}/?{qs}'
        response = self.client.get(url)
        assert response.status_code == 400
        assert response.data == self.error_message

    def test_incorrect_queries(self):
        """ Verify that a 400 status is returned if request contains incorrect query string. """
        qs = urllib.parse.urlencode({
            'query': 'title:',
            'course_run_ids': self.course_run.key,
            'course_uuids': self.course.uuid,
        })
        url = f'{self.url_base}/?{qs}'

        response = self.client.get(url)
        assert response.status_code == 400
class CreateCoursesTests(TestCase):
    def setUp(self):
        super(CreateCoursesTests, self).setUp()

        transcript_languages = LanguageTag.objects.all()[:2]
        self.subjects = SubjectFactory.create_batch(3)
        self.test_image = make_image_file('testimage.jpg')
        self.course = CourseFactory(subjects=self.subjects,
                                    image__from_file=self.test_image)

        self.command_name = 'import_metadata_courses'
        self.command_args = [
            '--start_id={}'.format(self.course.id),
            '--end_id={}'.format(self.course.id)
        ]

        # create multiple course-runs against course.
        course_runs = CourseRunFactory.create_batch(
            3,
            course=self.course,
            transcript_languages=transcript_languages,
            language=transcript_languages[0],
            short_description_override='Testing description')

        canonical_course_run = course_runs[0]
        for seat_type in ['honor', 'credit',
                          'verified']:  # to avoid same type seat creation.
            SeatFactory(course_run=canonical_course_run, type=seat_type)

        staff = PersonFactory.create_batch(2)
        canonical_course_run.staff.add(*staff)

        self.course.canonical_course_run = canonical_course_run
        self.course.save()

        # create org and assign to the course-metadata
        self.forganization_extension = factories.OrganizationExtensionFactory()
        self.organization = self.forganization_extension.organization
        self.course.authoring_organizations.add(self.organization)

    def test_course_run_created_successfully(self):
        """ Verify that publisher course and course_runs successfully."""
        self.command_args.append('--create_run={}'.format(True))

        call_command(self.command_name, *self.command_args)
        course = Publisher_Course.objects.all().first()

        self._assert_course(course)
        self._assert_course_run(course.course_runs.first(),
                                self.course.canonical_course_run)
        self._assert_seats(course.course_runs.first(),
                           self.course.canonical_course_run)

    def test_course_create_successfully(self):
        """ Verify that publisher course successfully."""
        call_command(self.command_name, *self.command_args)
        course = Publisher_Course.objects.all().first()

        self._assert_course(course)

    def test_course_create_without_video(self):
        """ Verify that publisher course successfully."""
        self.course.video = None
        self.course.save()
        self.command_args.append('--create_run={}'.format(True))

        call_command(self.command_name, *self.command_args)
        course = Publisher_Course.objects.all().first()

        self._assert_course(course)
        self._assert_course_run(course.course_runs.first(),
                                self.course.canonical_course_run)
        self._assert_seats(course.course_runs.first(),
                           self.course.canonical_course_run)

    def test_course_having_multiple_auth_organizations(self):
        """ Verify that if the course has multiple organization then that course will be
        imported to publisher but with only 1 organization.
        """
        # later that record will be updated with dual org manually.

        org2 = OrganizationFactory()
        self.course.authoring_organizations.add(org2)

        call_command(self.command_name, *self.command_args)
        course = Publisher_Course.objects.all().first()

        self._assert_course(course)

    def test_course_does_not_create_twice(self):
        """ Verify that course does not create two course with same title and number.
            Just update.
        """
        self.command_args.append('--create_run={}'.format(True))
        call_command(self.command_name, *self.command_args)
        self.assertEqual(Publisher_Course.objects.all().count(), 1)
        course = Publisher_Course.objects.all().first()
        self._assert_course(course)

        self.assertEqual(Publisher_CourseRun.objects.all().count(), 1)
        self._assert_course_run(course.course_runs.first(),
                                self.course.canonical_course_run)

        # try to import the course with same ids.
        call_command(self.command_name, *self.command_args)
        self.assertEqual(Publisher_Course.objects.all().count(), 1)
        course = Publisher_Course.objects.all().first()
        self._assert_course(course)
        self.assertEqual(Publisher_CourseRun.objects.all().count(), 1)
        self._assert_course_run(course.course_runs.first(),
                                self.course.canonical_course_run)

    def test_course_without_canonical_course_run(self):
        """ Verify that import works fine even if course has no canonical-course-run."""
        self.command_args.append('--create_run={}'.format(True))
        self.course.canonical_course_run = None
        self.course.save()

        with LogCapture(dataloader_logger.name) as log_capture:
            call_command(self.command_name, *self.command_args)
            publisher_course = Publisher_Course.objects.all().first()
            log_capture.check(
                (dataloader_logger.name, 'INFO',
                 'Import course with id [{}], number [{}].'.format(
                     publisher_course.id, publisher_course.number)),
                (dataloader_logger.name, 'WARNING',
                 'Canonical course-run not found for metadata course [{}].'.
                 format(self.course.uuid)),
            )

    @responses.activate
    def test_course_with_card_image_url(self):
        self.course.image.delete()
        self.test_image.open()
        responses.add(responses.GET,
                      self.course.card_image_url,
                      body=self.test_image.read(),
                      content_type='image/jpeg')
        call_command(self.command_name, *self.command_args)
        self.test_image.close()
        publisher_course = Publisher_Course.objects.all().first()
        self._assert_course(publisher_course)

    @responses.activate
    def test_course_with_non_existent_card_image_url(self):
        self.course.image.delete()
        request_status_code = 404
        responses.add(responses.GET,
                      self.course.card_image_url,
                      body=None,
                      content_type='image/jpeg',
                      status=request_status_code)
        with LogCapture(dataloader_logger.name,
                        level=logging.ERROR) as log_capture:
            call_command(self.command_name, *self.command_args)
            log_capture.check((
                dataloader_logger.name, 'ERROR',
                'Failed to download image for course [{}] from [{}]. Server responded with status [{}].'
                .format(self.course.uuid, self.course.card_image_url,
                        request_status_code)))

    def test_course_without_image(self):
        self.course.image.delete()
        self.course.card_image_url = None
        self.course.save()
        call_command(self.command_name, *self.command_args)
        publisher_course = Publisher_Course.objects.all().first()
        self._assert_course(publisher_course)

    def test_course_run_without_seats(self):
        """ Verify that import works fine even if course-run has no seats."""
        self.course.canonical_course_run.seats.all().delete()
        self.command_args.append('--create_run={}'.format(True))

        with LogCapture(dataloader_logger.name) as log_capture:
            call_command(self.command_name, *self.command_args)
            publisher_course = Publisher_Course.objects.all().first()
            publisher_run = publisher_course.course_runs.first()
            log_capture.check(
                (dataloader_logger.name, 'INFO',
                 'Import course with id [{}], number [{}].'.format(
                     publisher_course.id, publisher_course.number)),
                (dataloader_logger.name, 'INFO',
                 'Import course-run with id [{}], lms_course_id [{}].'.format(
                     publisher_run.id, publisher_run.lms_course_id)),
                (dataloader_logger.name, 'WARNING',
                 'No seats found for course-run [{}].'.format(
                     self.course.canonical_course_run.uuid)),
            )

    def _assert_course_image(self, publisher_course):
        if self.course.image or self.course.card_image_url:
            assert publisher_course.image.url is not None
        else:
            assert bool(publisher_course.image) is False

    def _assert_course(self, publisher_course):
        """ Verify that publisher course  and metadata course has correct values."""

        # assert organization
        self.assertEqual(publisher_course.organizations.first(),
                         self.organization)

        self.assertEqual(publisher_course.title, self.course.title)
        self.assertEqual(publisher_course.number, self.course.number)
        self.assertEqual(publisher_course.short_description,
                         self.course.short_description)
        self.assertEqual(publisher_course.full_description,
                         self.course.full_description)
        self.assertEqual(publisher_course.level_type, self.course.level_type)

        self.assertEqual(publisher_course.course_metadata_pk, self.course.pk)
        self.assertEqual(publisher_course.primary_subject, self.subjects[0])
        self.assertEqual(publisher_course.secondary_subject, self.subjects[1])
        self.assertEqual(publisher_course.tertiary_subject, self.subjects[2])

        if self.course.video:
            self.assertEqual(publisher_course.video_link,
                             self.course.video.src)
        else:
            self.assertFalse(publisher_course.video_link)

        assert publisher_course.prerequisites == self.course.prerequisites_raw
        assert publisher_course.syllabus == self.course.syllabus_raw
        assert publisher_course.expected_learnings == self.course.outcome
        self._assert_course_image(publisher_course)

    def _assert_course_run(self, publisher_course_run, metadata_course_run):
        """ Verify that publisher course-run and metadata course run has correct values."""

        self.assertEqual(publisher_course_run.start, metadata_course_run.start)
        self.assertEqual(publisher_course_run.end, metadata_course_run.end)
        self.assertEqual(publisher_course_run.enrollment_start,
                         metadata_course_run.enrollment_start)
        self.assertEqual(publisher_course_run.enrollment_end,
                         metadata_course_run.enrollment_end)

        self.assertEqual(publisher_course_run.min_effort,
                         metadata_course_run.min_effort)
        self.assertEqual(publisher_course_run.max_effort,
                         metadata_course_run.max_effort)
        self.assertEqual(publisher_course_run.length,
                         metadata_course_run.weeks_to_complete)
        self.assertEqual(publisher_course_run.language,
                         metadata_course_run.language)
        self.assertEqual(publisher_course_run.pacing_type,
                         metadata_course_run.pacing_type)
        self.assertEqual(publisher_course_run.card_image_url,
                         metadata_course_run.card_image_url)
        self.assertEqual(publisher_course_run.language,
                         metadata_course_run.language)
        self.assertEqual(publisher_course_run.lms_course_id,
                         metadata_course_run.key)
        self.assertEqual(publisher_course_run.short_description_override,
                         metadata_course_run.short_description_override)

        # assert ManytoMany fields.
        self.assertEqual(list(publisher_course_run.transcript_languages.all()),
                         list(metadata_course_run.transcript_languages.all()))
        self.assertEqual(list(publisher_course_run.staff.all()),
                         list(metadata_course_run.staff.all()))

    def _assert_seats(self, publisher_course_run, metadata_course_run):
        """ Verify that canonical course-run seats imported into publisher app with valid data."""
        metadata_seats = metadata_course_run.seats.all()
        publisher_seats = publisher_course_run.seats.all()
        self.assertEqual(metadata_seats.count(), publisher_seats.count())
        self.assertListEqual(
            sorted([(seat.type, seat.price, seat.credit_provider,
                     seat.currency) for seat in metadata_seats]),
            sorted([(seat.type, seat.price, seat.credit_provider,
                     seat.currency) for seat in publisher_seats]))
class CourseViewSetTests(SerializationMixin, APITestCase):
    def setUp(self):
        super(CourseViewSetTests, self).setUp()
        self.user = UserFactory(is_staff=True)
        self.request.user = self.user
        self.client.login(username=self.user.username, password=USER_PASSWORD)
        self.course = CourseFactory(partner=self.partner,
                                    title='Fake Test',
                                    key='edX+Fake101')
        self.org = OrganizationFactory(key='edX', partner=self.partner)
        self.course.authoring_organizations.add(self.org)  # pylint: disable=no-member

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

        with self.assertNumQueries(27):
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.data, self.serialize_course(self.course))

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

        with self.assertNumQueries(27):
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.data, self.serialize_course(self.course))

    def test_get_exclude_deleted_programs(self):
        """ Verify the endpoint returns no deleted associated programs """
        ProgramFactory(courses=[self.course], status=ProgramStatus.Deleted)
        url = reverse('api:v1:course-detail', kwargs={'key': self.course.key})
        with self.assertNumQueries(18):
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            self.assertEqual(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], status=ProgramStatus.Deleted)
        url = reverse('api:v1:course-detail', kwargs={'key': self.course.key})
        url += '?include_deleted_programs=1'
        with self.assertNumQueries(34):
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            self.assertEqual(
                response.data,
                self.serialize_course(
                    self.course,
                    extra_context={'include_deleted_programs': True}))

    def test_get_include_hidden_course_runs(self):
        """
        Verify the endpoint returns associated hidden course runs
        with the 'include_hidden_course_runs' flag set to True
        """
        CourseRunFactory(status=CourseRunStatus.Published,
                         end=datetime.datetime.now(pytz.UTC) +
                         datetime.timedelta(days=10),
                         enrollment_start=None,
                         enrollment_end=None,
                         hidden=True,
                         course=self.course)
        url = reverse('api:v1:course-detail', kwargs={'key': self.course.key})
        url += '?include_hidden_course_runs=1'

        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data, self.serialize_course(self.course))

    @ddt.data(1, 0)
    def test_marketable_course_runs_only(self, marketable_course_runs_only):
        """
        Verify that a client requesting marketable_course_runs_only only receives
        course runs that are published, have seats, and can still be enrolled in.
        """
        # Published course run with a seat, no enrollment start or end, and an end date in the future.
        enrollable_course_run = CourseRunFactory(
            status=CourseRunStatus.Published,
            end=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10),
            enrollment_start=None,
            enrollment_end=None,
            course=self.course)
        SeatFactory(course_run=enrollable_course_run)

        # Unpublished course run with a seat.
        unpublished_course_run = CourseRunFactory(
            status=CourseRunStatus.Unpublished, course=self.course)
        SeatFactory(course_run=unpublished_course_run)

        # Published course run with no seats.
        no_seats_course_run = CourseRunFactory(
            status=CourseRunStatus.Published, course=self.course)

        # Published course run with a seat and an end date in the past.
        closed_course_run = CourseRunFactory(
            status=CourseRunStatus.Published,
            end=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10),
            course=self.course)
        SeatFactory(course_run=closed_course_run)

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

        assert response.status_code == 200

        if marketable_course_runs_only:
            # Emulate prefetching behavior.
            for course_run in (unpublished_course_run, no_seats_course_run,
                               closed_course_run):
                course_run.delete()

        assert response.data == self.serialize_course(self.course)

    @ddt.data(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)

    @ddt.data(1, 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)

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

        with self.assertNumQueries(35):
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200)
            self.assertListEqual(
                response.data['results'],
                self.serialize_course(Course.objects.all().order_by(
                    Lower('key')),
                                      many=True))

    def test_list_query(self):
        """ Verify the endpoint returns a filtered list of courses """
        title = 'Some random title'
        courses = CourseFactory.create_batch(3, title=title)
        courses = sorted(courses, key=lambda course: course.key.lower())
        query = 'title:' + title
        url = '{root}?q={query}'.format(root=reverse('api:v1:course-list'),
                                        query=query)

        with self.assertNumQueries(51):
            response = self.client.get(url)
            self.assertListEqual(response.data['results'],
                                 self.serialize_course(courses, many=True))

    def test_list_key_filter(self):
        """ Verify the endpoint returns a list of courses filtered by the specified keys. """
        courses = CourseFactory.create_batch(3, partner=self.partner)
        courses = sorted(courses, key=lambda course: course.key.lower())
        keys = ','.join([course.key for course in courses])
        url = '{root}?keys={keys}'.format(root=reverse('api:v1:course-list'),
                                          keys=keys)

        with self.assertNumQueries(51):
            response = self.client.get(url)
            self.assertListEqual(response.data['results'],
                                 self.serialize_course(courses, many=True))

    def test_list_uuid_filter(self):
        """ Verify the endpoint returns a list of courses filtered by the specified uuid. """
        courses = CourseFactory.create_batch(3, partner=self.partner)
        courses = sorted(courses, key=lambda course: course.key.lower())
        uuids = ','.join([str(course.uuid) for course in courses])
        url = '{root}?uuids={uuids}'.format(root=reverse('api:v1:course-list'),
                                            uuids=uuids)

        with self.assertNumQueries(51):
            response = self.client.get(url)
            self.assertListEqual(response.data['results'],
                                 self.serialize_course(courses, many=True))

    def test_list_exclude_utm(self):
        """ Verify the endpoint returns marketing URLs without UTM parameters. """
        url = reverse('api:v1:course-list') + '?exclude_utm=1'

        response = self.client.get(url)
        context = {'exclude_utm': 1}
        self.assertEqual(
            response.data['results'],
            self.serialize_course([self.course],
                                  many=True,
                                  extra_context=context))

    @ddt.data(
        ('get', False, False, True),
        ('options', False, False, True),
        ('post', False, False, False),
        ('post', False, True, True),
        ('post', True, False, True),
    )
    @ddt.unpack
    def test_editor_access_list_endpoint(self, method, is_staff, in_org,
                                         allowed):
        """ Verify we check editor access correctly when hitting the courses endpoint. """
        self.user.is_staff = is_staff
        self.user.save()

        if in_org:
            org_ext = OrganizationExtensionFactory(organization=self.org)
            self.user.groups.add(org_ext.group)

        response = getattr(self.client, method)(reverse('api:v1:course-list'),
                                                {
                                                    'org': self.org.key
                                                },
                                                format='json')

        if not allowed:
            self.assertEqual(response.status_code, 403)
        else:
            self.assertNotEqual(response.status_code, 403)

    @ddt.data(
        ('get', False, False, False, True),
        ('options', False, False, False, True),
        ('put', False, False, False, False),  # no access
        ('put', True, False, False, True),  # is staff
        ('patch', False, True, False, False),  # is in org
        ('patch', False, False, True, False),  # is editor but not in org
        ('put', False, True, True, True),  # editor and in org
    )
    @ddt.unpack
    def test_editor_access_detail_endpoint(self, method, is_staff, in_org,
                                           is_editor, allowed):
        """ Verify we check editor access correctly when hitting the course object endpoint. """
        self.user.is_staff = is_staff
        self.user.save()

        # Add another editor, because we have some logic that allows access anyway if a course has no valid editors.
        # That code path is checked in test_course_without_editors below.
        org_ext = OrganizationExtensionFactory(organization=self.org)
        user2 = UserFactory()
        user2.groups.add(org_ext.group)
        CourseEditorFactory(user=user2, course=self.course)

        if in_org:
            # Editors must be in the org to get editor access
            self.user.groups.add(org_ext.group)

        if is_editor:
            CourseEditorFactory(user=self.user, course=self.course)

        response = getattr(self.client,
                           method)(reverse('api:v1:course-detail',
                                           kwargs={'key': self.course.uuid}))

        if not allowed:
            self.assertEqual(response.status_code, 404)
        else:
            # We'll probably fail because we didn't include the right data - but at least we'll have gotten in
            self.assertNotEqual(response.status_code, 404)

    def test_editable_list_gives_drafts(self):
        draft = CourseFactory(partner=self.partner,
                              uuid=self.course.uuid,
                              key=self.course.key,
                              draft=True)
        draft_course_run = CourseRunFactory(
            status=CourseRunStatus.Published,
            end=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10),
            course=draft,
            draft=True,
        )
        self.course.draft_version = draft
        self.course.save()
        extra = CourseFactory(partner=self.partner, key=self.course.key +
                              'Z')  # set key so it sorts later

        response = self.client.get(
            reverse('api:v1:course-list') + '?editable=1')
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data['results'],
                         self.serialize_course([draft, extra], many=True))
        self.assertEqual(len(response.data['results'][0]['course_runs']), 1)
        self.assertEqual(response.data['results'][0]['course_runs'][0]['uuid'],
                         str(draft_course_run.uuid))

    def test_editable_get_gives_drafts(self):
        draft = CourseFactory(partner=self.partner,
                              uuid=self.course.uuid,
                              key=self.course.key,
                              draft=True)
        self.course.draft_version = draft
        self.course.save()
        extra = CourseFactory(partner=self.partner)

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

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

    def test_course_without_editors(self):
        """ Verify we can modify a course with no editors if we're in its authoring org. """
        url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
        self.user.is_staff = False
        self.user.save()
        self.course.draft = True
        self.course.save()

        # Try without being in the organization nor an editor
        self.assertEqual(self.client.patch(url).status_code, 404)

        # Add to authoring org, and we should be let in
        org_ext = OrganizationExtensionFactory(organization=self.org)
        self.user.groups.add(org_ext.group)
        self.assertNotEqual(self.client.patch(url).status_code, 404)

        # Now add a random other user as an editor to the course, so that we will no longer be granted access.
        editor = UserFactory()
        CourseEditorFactory(user=editor, course=self.course)
        editor.groups.add(org_ext.group)
        self.assertEqual(self.client.patch(url).status_code, 404)

        # But if the editor is no longer valid (even though they exist), we're back to having access.
        editor.groups.remove(org_ext.group)
        self.assertNotEqual(self.client.patch(url).status_code, 404)

        # And finally, for a sanity check, confirm we have access when we become an editor also
        CourseEditorFactory(user=self.user, course=self.course)
        self.assertNotEqual(self.client.patch(url).status_code, 404)

    def test_delete_not_allowed(self):
        """ Verify we don't allow deleting a course from the API. """
        response = self.client.delete(
            reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}))
        self.assertEqual(response.status_code, 405)

    def test_create_without_authentication(self):
        """ Verify authentication is required when creating a course. """
        self.client.logout()
        Course.objects.all().delete()

        url = reverse('api:v1:course-list')
        response = self.client.post(url)
        assert response.status_code == 403
        assert Course.objects.count() == 0

    def create_course(self, data=None, update=True):
        url = reverse('api:v1:course-list')
        if update:
            course_data = {
                'title': 'Course title',
                'number': 'test101',
                'org': self.org.key,
                'mode': 'audit',
            }
            course_data.update(data or {})
        else:
            course_data = data or {}
        return self.client.post(url, course_data, format='json')

    @oauth_login
    @responses.activate
    def test_create_with_authentication_verified_mode(self):
        course_data = {
            'mode': 'verified',
            'price': 100,
        }
        ecom_url = self.partner.ecommerce_api_url + 'products/'
        ecom_entitlement_data = {
            'product_class': 'Course Entitlement',
            'title': 'Course title',
            'price': course_data['price'],
            'certificate_type': course_data['mode'],
            'uuid': '00000000-0000-0000-0000-000000000000',
            'stockrecords': [{
                'partner_sku': 'ABC123'
            }],
        }
        responses.add(responses.POST,
                      ecom_url,
                      body=json.dumps(ecom_entitlement_data),
                      content_type='application/json',
                      status=201,
                      match_querystring=True)
        response = self.create_course(course_data)

        course = Course.everything.last()
        self.assertDictEqual(response.data, self.serialize_course(course))
        self.assertEqual(response.status_code, 201)
        expected_course_key = '{org}+{number}'.format(org=self.org.key,
                                                      number='test101')
        self.assertEqual(course.key, expected_course_key)
        self.assertEqual(course.title, 'Course title')
        self.assertListEqual(list(course.authoring_organizations.all()),
                             [self.org])
        self.assertEqual(1, CourseEntitlement.everything.count())  # pylint: disable=no-member

    @oauth_login
    def test_create_with_authentication_audit_mode(self):
        """
        When creating with audit mode, no entitlement should be created.
        """
        response = self.create_course()

        course = Course.everything.last()
        self.assertDictEqual(response.data, self.serialize_course(course))
        self.assertEqual(response.status_code, 201)
        expected_course_key = '{org}+{number}'.format(org=self.org.key,
                                                      number='test101')
        self.assertEqual(course.key, expected_course_key)
        self.assertEqual(course.title, 'Course title')
        self.assertListEqual(list(course.authoring_organizations.all()),
                             [self.org])
        self.assertEqual(0, CourseEntitlement.objects.count())

    @oauth_login
    def test_create_makes_draft(self):
        """ When creating a course, it should start as a draft. """
        ecom_url = self.partner.ecommerce_api_url + 'products/'
        ecom_entitlement_data = {
            'product_class': 'Course Entitlement',
            'title': 'Course title',
            'price': '0.0',
            'certificate_type': 'verified',
            'uuid': '00000000-0000-0000-0000-000000000000',
            'stockrecords': [{
                'partner_sku': 'ABC123'
            }],
        }
        responses.add(responses.POST,
                      ecom_url,
                      body=json.dumps(ecom_entitlement_data),
                      content_type='application/json',
                      status=201,
                      match_querystring=True)

        response = self.create_course({'mode': 'verified'})
        self.assertEqual(response.status_code, 201)

        course = Course.everything.last()
        self.assertTrue(course.draft)
        self.assertTrue(course.entitlements.first().draft)

    @oauth_login
    def test_create_fails_if_official_version_exists(self):
        """ When creating a course, it should not create one if an official version already exists. """
        response = self.create_course({'number': 'Fake101'})
        self.assertEqual(response.status_code, 400)
        expected_error_message = 'Failed to set course data: A course with key {key} already exists.'
        self.assertEqual(response.data,
                         expected_error_message.format(key=self.course.key))

    def test_create_fails_with_missing_field(self):
        response = self.create_course(
            {
                'title': 'Course title',
                'org': self.org.key,
                'mode': 'audit',
            },
            update=False)
        self.assertEqual(response.status_code, 400)
        expected_error_message = 'Incorrect data sent. Missing value for: [number].'
        self.assertEqual(response.data, expected_error_message)

    def test_create_fails_with_nonexistent_org(self):
        response = self.create_course({'org': 'fake org'})
        self.assertEqual(response.status_code, 400)
        expected_error_message = 'Incorrect data sent. Organization does not exist.'
        self.assertEqual(response.data, expected_error_message)

    def test_create_fails_with_nonexistent_mode(self):
        response = self.create_course({'mode': 'fake mode'})
        self.assertEqual(response.status_code, 400)
        expected_error_message = 'Incorrect data sent. Entitlement Track does not exist.'
        self.assertEqual(response.data, expected_error_message)

    @ddt.data(
        ({
            'title': 'Course title',
            'number': 'test101',
            'org': 'fake org',
            'mode': 'fake mode'
        },
         'Incorrect data sent. Organization does not exist. Entitlement Track does not exist.'
         ),
        ({
            'title': 'Course title',
            'org': 'edX',
            'mode': 'fake mode'
        },
         'Incorrect data sent. Missing value for: [number]. Entitlement Track does not exist.'
         ),
        ({
            'title': 'Course title',
            'org': 'fake org',
            'mode': 'audit'
        },
         'Incorrect data sent. Missing value for: [number]. Organization does not exist.'
         ),
        ({
            'number': 'test101',
            'org': 'fake org',
            'mode': 'fake mode'
        },
         'Incorrect data sent. Missing value for: [title]. Organization does not exist. '
         'Entitlement Track does not exist.'),
    )
    @ddt.unpack
    def test_create_fails_with_multiple_errors(self, course_data,
                                               expected_error_message):
        response = self.create_course(course_data, update=False)
        self.assertEqual(response.status_code, 400)
        self.assertEqual(response.data, expected_error_message)

    def test_create_with_api_exception(self):
        with mock.patch(
                # We are using get_course_key because it is called prior to tryig to contact the
                # e-commerce service and still gives the effect of an api exception.
                'course_discovery.apps.api.v1.views.courses.CourseViewSet.get_course_key',
                side_effect=IntegrityError):
            with LogCapture(course_logger.name) as log_capture:
                response = self.create_course()
                self.assertEqual(response.status_code, 400)
                log_capture.check((
                    course_logger.name,
                    'ERROR',
                    'An error occurred while setting Course data.',
                ))

    @oauth_login
    @responses.activate
    def test_create_with_ecom_api_exception(self):
        ecom_url = self.partner.ecommerce_api_url + 'products/'
        expected_error_message = 'Missing or bad value for: [title].'
        responses.add(
            responses.POST,
            ecom_url,
            body=expected_error_message,
            status=400,
        )
        with LogCapture(course_logger.name) as log_capture:
            response = self.create_course({'mode': 'verified', 'price': 100})
            self.assertEqual(response.status_code, 400)
            log_capture.check((
                course_logger.name, 'ERROR',
                'The following error occurred while setting the Course Entitlement data in E-commerce: '
                '{ecom_error}'.format(ecom_error=expected_error_message)))

    def test_update_without_authentication(self):
        """ Verify authentication is required when updating a course. """
        self.client.logout()
        Course.objects.all().delete()

        url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
        response = self.client.patch(url)
        assert response.status_code == 403
        assert Course.objects.count() == 0

    @ddt.data('put', 'patch')
    @oauth_login
    @responses.activate
    def test_update_success(self, method):
        entitlement = CourseEntitlementFactory(course=self.course)
        url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
        course_data = {
            'title':
            'Course title',
            'partner':
            self.partner.id,
            'key':
            self.course.key,
            'entitlements': [
                {
                    'mode': entitlement.mode.slug,
                    'price': 1000,
                    'sku': entitlement.sku,
                    'expires': entitlement.expires,
                },
            ],
            # The API is expecting the image to be base64 encoded. We are simulating that here.
            'image':
            'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY'
            '42YAAAAASUVORK5CYII=',
            'video': {
                'src': 'https://link.to.video.for.testing/watch?t_s=5'
            },
        }
        ecom_url = '{0}stockrecords/{1}/'.format(
            self.partner.ecommerce_api_url, entitlement.sku)
        responses.add(
            responses.PUT,
            ecom_url,
            status=200,
        )
        response = getattr(self.client, method)(url,
                                                course_data,
                                                format='json')
        self.assertEqual(response.status_code, 200)

        course = Course.everything.get(uuid=self.course.uuid, draft=True)
        self.assertEqual(course.title, 'Course title')
        self.assertEqual(course.entitlements.first().price, 1000)
        self.assertDictEqual(response.data, self.serialize_course(course))

    @oauth_login
    @responses.activate
    def test_update_operates_on_drafts(self):
        CourseEntitlementFactory(course=self.course)
        self.assertFalse(
            Course.everything.filter(uuid=self.course.uuid,
                                     draft=True).exists())  # sanity check

        url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
        response = self.client.patch(url, {'title': 'Title'}, format='json')
        self.assertEqual(response.status_code, 200)

        course = Course.everything.get(uuid=self.course.uuid, draft=True)
        self.assertTrue(course.entitlements.first().draft)
        self.assertEqual(course.title, 'Title')

        self.course.refresh_from_db()
        self.assertFalse(self.course.draft)
        self.assertFalse(self.course.entitlements.first().draft)
        self.assertEqual(self.course.title, 'Fake Test')

    @ddt.data(
        (
            {
                'entitlements': [{}]
            },
            'Entitlements must have a mode specified.',
        ),
        ({
            'entitlements': [{
                'mode': 'NOPE'
            }]
        }, 'Entitlement mode NOPE not found.'),
        ({
            'entitlements': [{
                'mode': 'mode2'
            }]
        },
         'Existing entitlement not found for mode mode2 in course Org/Course/Number.'
         ),
        ({
            'entitlements': [{
                'mode': 'mode1'
            }]
        }, 'Entitlement does not have a valid SKU assigned.'),
    )
    @ddt.unpack
    def test_update_fails_with_multiple_errors(self, course_data,
                                               expected_error_message):
        course = CourseFactory(partner=self.partner, key='Org/Course/Number')
        url = reverse('api:v1:course-detail', kwargs={'key': course.uuid})
        mode1 = SeatTypeFactory(name='Mode1')
        SeatTypeFactory(name='Mode2')
        CourseEntitlementFactory(course=course, mode=mode1, sku=None)
        response = self.client.patch(url, course_data, format='json')
        self.assertEqual(response.status_code, 400)
        self.assertEqual(response.data, expected_error_message)

    def test_update_with_api_exception(self):
        url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
        course_data = {
            'title': 'Course title',
            'entitlements': [
                {
                    'price': 1000,
                },
            ],
        }
        with mock.patch(
                'course_discovery.apps.api.v1.views.courses.CourseViewSet.update_entitlement',
                side_effect=IntegrityError):
            with LogCapture(course_logger.name) as log_capture:
                response = self.client.patch(url, course_data, format='json')
                self.assertEqual(response.status_code, 400)
                log_capture.check((
                    course_logger.name,
                    'ERROR',
                    'An error occurred while setting Course data.',
                ))

    @oauth_login
    @responses.activate
    def test_update_with_ecom_api_exception(self):
        entitlement = CourseEntitlementFactory(course=self.course)
        url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
        course_data = {
            'title': 'Course title',
            'entitlements': [
                {
                    'mode': entitlement.mode.slug,
                    'price': 1000,
                },
            ],
        }
        ecom_url = '{0}stockrecords/{1}/'.format(
            self.partner.ecommerce_api_url, entitlement.sku)
        expected_error_message = 'Nope'
        responses.add(
            responses.PUT,
            ecom_url,
            body=expected_error_message,
            status=400,
        )
        with LogCapture(course_logger.name) as log_capture:
            response = self.client.patch(url, course_data, format='json')
            self.assertEqual(response.status_code, 400)
            log_capture.check((
                course_logger.name, 'ERROR',
                'The following error occurred while setting the Course Entitlement data in E-commerce: '
                '{ecom_error}'.format(ecom_error=expected_error_message)))

    @oauth_login
    @responses.activate
    def test_options(self):
        SubjectFactory(name='Subject1')
        CourseEntitlementFactory(course=self.course,
                                 mode=SeatType.objects.get(slug='verified'))

        url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})
        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.level_type.name,
                             'value': self.course.level_type.name
                         }])
        self.assertEqual(
            data['entitlements']['child']['children']['mode']['choices'],
            [{
                'display_name': 'Audit',
                'value': 'audit'
            }, {
                'display_name': 'Credit',
                'value': 'credit'
            }, {
                'display_name': 'Professional',
                'value': 'professional'
            }, {
                'display_name': 'Verified',
                'value': 'verified'
            }])
        self.assertEqual(data['subjects']['child']['choices'],
                         [{
                             'display_name': 'Subject1',
                             'value': 'subject1'
                         }])
        self.assertFalse(
            'choices' in
            data['partner'])  # we don't whitelist partner to show its choices
Exemplo n.º 7
0
    def test_ensure_draft_world_not_draft_course_given(self):
        course = CourseFactory()
        entitlement = CourseEntitlementFactory(course=course)
        course.entitlements.add(entitlement)
        course_runs = CourseRunFactory.create_batch(3, course=course)
        for run in course_runs:
            course.course_runs.add(run)
        course.canonical_course_run = course_runs[0]
        course.save()
        org = OrganizationFactory()
        course.authoring_organizations.add(org)  # pylint: disable=no-member

        ensured_draft_course = utils.ensure_draft_world(course)
        not_draft_course = Course.objects.get(uuid=course.uuid)

        self.assertNotEqual(ensured_draft_course, not_draft_course)
        self.assertEqual(ensured_draft_course.uuid, not_draft_course.uuid)
        self.assertTrue(ensured_draft_course.draft)

        # Check slugs are equal
        self.assertEqual(ensured_draft_course.slug, not_draft_course.slug)

        # Check authoring orgs are equal
        self.assertEqual(
            list(ensured_draft_course.authoring_organizations.all()),
            list(not_draft_course.authoring_organizations.all()))

        # Check canonical course run was updated
        self.assertNotEqual(ensured_draft_course.canonical_course_run,
                            not_draft_course.canonical_course_run)
        self.assertTrue(ensured_draft_course.canonical_course_run.draft)
        self.assertEqual(ensured_draft_course.canonical_course_run.uuid,
                         not_draft_course.canonical_course_run.uuid)

        # Check course runs all share the same UUIDs, but are now all drafts
        not_draft_course_runs_uuids = [run.uuid for run in course_runs]
        draft_course_runs_uuids = [
            run.uuid for run in ensured_draft_course.course_runs.all()
        ]
        self.assertListEqual(draft_course_runs_uuids,
                             not_draft_course_runs_uuids)

        # Entitlement checks
        draft_entitlement = ensured_draft_course.entitlements.first()
        not_draft_entitlement = not_draft_course.entitlements.first()
        self.assertNotEqual(draft_entitlement, not_draft_entitlement)
        self.assertEqual(draft_entitlement.price, not_draft_entitlement.price)
        self.assertEqual(draft_entitlement.sku, not_draft_entitlement.sku)
        self.assertNotEqual(draft_entitlement.course,
                            not_draft_entitlement.course)
        self.assertEqual(draft_entitlement.course.uuid,
                         not_draft_entitlement.course.uuid)

        # Check official and draft versions match up
        self.assertEqual(ensured_draft_course.official_version,
                         not_draft_course)
        self.assertEqual(not_draft_course.draft_version, ensured_draft_course)

        self.assertEqual(draft_entitlement.official_version,
                         not_draft_entitlement)
        self.assertEqual(not_draft_entitlement.draft_version,
                         draft_entitlement)