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