def update(self, request, *args, **kwargs): """ Update one, or more, fields for a course run. """ course_run = self.get_object() course_run = ensure_draft_world(course_run) # always work on drafts # Disallow patch or put if the course run is in review. if course_run.in_review: return Response( _('Course run is in review. Editing disabled.'), status=status.HTTP_403_FORBIDDEN ) # If changes are made after review and before publish, # revert status to unpublished. if course_run.status == CourseRunStatus.Reviewed: request.data['status'] = CourseRunStatus.Unpublished # Sending draft=False triggers the review process draft = request.data.pop('draft', True) # Don't let draft parameter trickle down if not draft and course_run.status != CourseRunStatus.Published: request.data['status'] = CourseRunStatus.LegalReview response = super().update(request, *args, **kwargs) self.push_to_studio(request, course_run, create=False) return response
def create(self, request, *args, **kwargs): """ Create a course run object. """ # Set a pacing default when creating (studio requires this to be set, even though discovery does not) request.data.setdefault('pacing_type', 'instructor_paced') # Guard against externally setting the draft state request.data.pop('draft', None) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) # Grab any existing course run for this course (we'll use it when talking to studio to form basis of rerun) course_key = request.data['course'] # required field course = Course.objects.filter_drafts().get(key=course_key) course = ensure_draft_world(course) old_course_run = course.canonical_course_run # And finally, save to database and push to studio course_run = serializer.save() course_run.draft = True course_run.save() self.push_to_studio(request, course_run, create=True, old_course_run=old_course_run) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def _get_course_or_404(partner, course_uuid): try: course = Course.objects.filter_drafts().get(partner=partner, uuid=course_uuid) return ensure_draft_world(course) except Course.DoesNotExist as course_does_not_exist: raise Http404 from course_does_not_exist
def test_ensure_draft_world_draft_obj_given(self): course_run = CourseRunFactory(draft=True) ensured_draft_course_run = utils.ensure_draft_world(course_run) self.assertEqual(ensured_draft_course_run, course_run) self.assertEqual(ensured_draft_course_run.id, course_run.id) self.assertEqual(ensured_draft_course_run.uuid, course_run.uuid) self.assertEqual(ensured_draft_course_run.draft, course_run.draft)
def test_published(self): person = PersonFactory() org = OrganizationFactory() primary = DiscoveryCourseRunFactory(key=self.course_run.lms_course_id, staff=[person], status=CourseRunStatus.Unpublished, announcement=None, course__partner=self.partner, end=None, enrollment_end=None) second = DiscoveryCourseRunFactory(course=primary.course, status=CourseRunStatus.Published, end=None, enrollment_end=None, start=(primary.start + datetime.timedelta(days=1))) third = DiscoveryCourseRunFactory(course=primary.course, status=CourseRunStatus.Published, end=datetime.datetime(2010, 1, 1, tzinfo=UTC), enrollment_end=None) primary.course.authoring_organizations.add(org) self.course.organizations.add(org) ensure_draft_world(DiscoveryCourse.objects.get(pk=primary.course.pk)) pc = UserFactory() factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.ProjectCoordinator, user=pc) factories.OrganizationUserRoleFactory(organization=org, role=InternalUserRole.ProjectCoordinator, user=pc) self.mock_api_client() lookup_value = getattr(primary, self.publisher.unique_field) self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value) lookup_value = getattr(third, self.publisher.unique_field) self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value) self.mock_get_redirect_form() self.mock_add_redirect() self.course_run.course_run_state.name = CourseRunStateChoices.Approved self.course_run.course_run_state.change_state(CourseRunStateChoices.Published, self.user, self.site) primary.refresh_from_db() second.refresh_from_db() third.refresh_from_db() self.assertIsNotNone(primary.announcement) self.assertEqual(primary.status, CourseRunStatus.Published) self.assertEqual(second.status, CourseRunStatus.Published) # doesn't change end=None runs self.assertEqual(third.status, CourseRunStatus.Unpublished) # does change archived runs # Check email was sent (only one - from old publisher, not new publisher flow) assert len(mail.outbox) == 1 message = mail.outbox[0] self.assertTrue(message.subject.startswith('Publication complete: ')) self.assertEqual(message.to, [self.user.email]) self.assertEqual(message.cc, [pc.email])
def test_affects_drafts_too(self): draft_course = ensure_draft_world(Course.objects.get(pk=self.course.pk)) self.run_command() draft_course.refresh_from_db() assert self.course.type == self.va_course_type assert self.audit_run.type == self.audit_run_type assert self.verified_run.type == self.va_run_type assert draft_course.type == self.va_course_type assert set(draft_course.course_runs.values_list('type', flat=True)) \ == {self.audit_run_type.id, self.va_run_type.id}
def test_ensure_draft_world_not_draft_course_run_given(self): course = CourseFactory() course_run = CourseRunFactory(course=course) verified_seat = SeatFactory(type='verified', course_run=course_run) audit_seat = SeatFactory(type='audit', course_run=course_run) course_run.seats.add(verified_seat, audit_seat) ensured_draft_course_run = utils.ensure_draft_world(course_run) not_draft_course_run = CourseRun.objects.get(uuid=course_run.uuid) self.assertNotEqual(ensured_draft_course_run, not_draft_course_run) self.assertEqual(ensured_draft_course_run.uuid, not_draft_course_run.uuid) self.assertTrue(ensured_draft_course_run.draft) self.assertNotEqual(ensured_draft_course_run.course, not_draft_course_run.course) self.assertEqual(ensured_draft_course_run.course.uuid, not_draft_course_run.course.uuid) # Check slugs are equal self.assertEqual(ensured_draft_course_run.slug, not_draft_course_run.slug) # Seat checks draft_seats = ensured_draft_course_run.seats.all() not_draft_seats = not_draft_course_run.seats.all() self.assertNotEqual(draft_seats, not_draft_seats) self.assertEqual(len(draft_seats), len(not_draft_seats)) for i, __ in enumerate(draft_seats): self.assertEqual(draft_seats[i].price, not_draft_seats[i].price) self.assertEqual(draft_seats[i].sku, not_draft_seats[i].sku) self.assertNotEqual(draft_seats[i].course_run, not_draft_seats[i].course_run) self.assertEqual(draft_seats[i].course_run.uuid, not_draft_seats[i].course_run.uuid) self.assertEqual(draft_seats[i].official_version, not_draft_seats[i]) self.assertEqual(not_draft_seats[i].draft_version, draft_seats[i]) # Check draft course is also created draft_course = ensured_draft_course_run.course not_draft_course = Course.objects.get(uuid=course.uuid) self.assertNotEqual(draft_course, not_draft_course) self.assertEqual(draft_course.uuid, not_draft_course.uuid) self.assertTrue(draft_course.draft) # Check official and draft versions match up self.assertEqual(ensured_draft_course_run.official_version, not_draft_course_run) self.assertEqual(not_draft_course_run.draft_version, ensured_draft_course_run)
def update(self, request, **kwargs): # logging to help debug error around course url slugs incrementing log.info('The raw course run data coming from publisher is {}.'.format( request.data)) # Update one, or more, fields for a course run. course_run = self.get_object() course_run = ensure_draft_world(course_run) # always work on drafts partial = kwargs.pop('partial', False) # Sending draft=False triggers the review process for unpublished courses draft = request.data.pop( 'draft', True) # Don't let draft parameter trickle down prices = request.data.pop('prices', {}) serializer = self.get_serializer(course_run, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) # Handle staff update on course run in review with valid status transition if (request.user.is_staff and course_run.in_review and 'status' in request.data and request.data['status'] in CourseRunStatus.INTERNAL_STATUS_TRANSITIONS): return self.handle_internal_review(request, serializer) # Handle regular non-internal update request.data.pop('status', None) # Status management is handled in the model serializer.validated_data.pop( 'status', None) # Status management is handled in the model # Disallow patch or put if the course run is in review. if course_run.in_review: return Response(_('Course run is in review. Editing disabled.'), status=status.HTTP_403_FORBIDDEN) # Disallow internal review fields when course run is not in review for key in request.data.keys(): if key in CourseRun.INTERNAL_REVIEW_FIELDS: return Response(_('Invalid parameter'), status=status.HTTP_400_BAD_REQUEST) changed_fields = reviewable_data_has_changed( course_run, serializer.validated_data.items(), CourseRun.STATUS_CHANGE_EXEMPT_FIELDS) response = self._update_course_run(course_run, draft, bool(changed_fields), serializer, request, prices) self.update_course_run_image_in_studio(course_run) return response
def update_course(self, data, partial=False): """ Updates an existing course from incoming data. """ # Pop nested writables that we will handle ourselves (the serializer won't handle them) entitlements_data = data.pop('entitlements', []) image_data = data.pop('image', None) video_data = data.pop('video', None) # Get and validate object serializer course = self.get_object() course = ensure_draft_world(course) # always work on drafts serializer = self.get_serializer(course, data=data, partial=partial) serializer.is_valid(raise_exception=True) # First, update nested entitlements entitlements = [] for entitlement_data in entitlements_data: entitlements.append( self.update_entitlement(course, entitlement_data, partial=partial)) # Save video if a new video source is provided if (video_data and video_data.get('src') and (not course.video or video_data.get('src') != course.video.src)): video, __ = Video.objects.get_or_create(src=video_data['src']) course.video = video # Save image and convert to the correct format if image_data and isinstance( image_data, str) and image_data.startswith('data:image'): # base64 encoded image - decode file_format, imgstr = image_data.split( ';base64,') # format ~= data:image/X;base64,/xxxyyyzzz/ ext = file_format.split('/')[-1] # guess file extension image_data = ContentFile(base64.b64decode(imgstr), name='tmp.' + ext) # The image requires a name in order to save; however, we don't do anything with that name so # we are passing in an empty string so it doesn't break. None is not supported. course.image.save('', image_data) # Then the course itself serializer.save() return Response(serializer.data)
def update_course(self, data, partial=False): # pylint: disable=too-many-statements """ Updates an existing course from incoming data. """ changed = False # Sending draft=False means the course data is live and updates should be pushed out immediately draft = data.pop('draft', True) image_data = data.pop('image', None) video_data = data.pop('video', None) url_slug = data.pop('url_slug', '') # Get and validate object serializer course = self.get_object() course = ensure_draft_world(course) # always work on drafts serializer = self.get_serializer(course, data=data, partial=partial) serializer.is_valid(raise_exception=True) # TEMPORARY - log incoming request (subject and prices) for all course updates, see Jira DISCO-1593 self.log_request_subjects_and_prices(data, course) # First, update course entitlements if data.get('type') or data.get('prices'): entitlements = [] prices = data.get('prices', {}) course_type = CourseType.objects.get( uuid=data.get('type')) if data.get('type') else course.type entitlement_types = course_type.entitlement_types.all() for entitlement_type in entitlement_types: price = prices.get(entitlement_type.slug) if price is None: continue entitlement, did_change = self.update_entitlement( course, entitlement_type, price, partial=partial) entitlements.append(entitlement) changed = changed or did_change # Deleting entitlements here since they would be orphaned otherwise. # One example of how this situation can happen is if a course team is switching between # "Verified and Audit" and "Audit Only" before actually publishing their course run. course.entitlements.exclude(mode__in=entitlement_types).delete() course.entitlements.set(entitlements) # Save video if a new video source is provided if (video_data and video_data.get('src') and (not course.video or video_data.get('src') != course.video.src)): video, __ = Video.objects.get_or_create(src=video_data['src']) course.video = video # Save image and convert to the correct format if image_data and isinstance( image_data, str) and image_data.startswith('data:image'): # base64 encoded image - decode file_format, imgstr = image_data.split( ';base64,') # format ~= data:image/X;base64,/xxxyyyzzz/ ext = file_format.split('/')[-1] # guess file extension image_data = ContentFile( base64.b64decode(imgstr), name='tmp.{extension}'.format(extension=ext)) course.image.save(image_data.name, image_data) if data.get('collaborators'): collaborators_uuids = data.get('collaborators') collaborators = Collaborator.objects.filter( uuid__in=collaborators_uuids) course.collaborators.add(*collaborators) # If price didnt change, check the other fields on the course # (besides image and video, they are popped off above) changed = changed or reviewable_data_has_changed( course, serializer.validated_data.items()) if url_slug: validators.validate_slug(url_slug) all_course_historical_slugs_excluding_present = CourseUrlSlug.objects.filter( url_slug=url_slug, partner=course.partner).exclude(course__uuid=course.uuid) if all_course_historical_slugs_excluding_present.exists(): raise Exception( _('Course edit was unsuccessful. The course URL slug ‘[{url_slug}]’ is already in use. ' 'Please update this field and try again.').format( url_slug=url_slug)) # Then the course itself course = serializer.save() if url_slug: course.set_active_url_slug(url_slug) if not draft: for course_run in course.active_course_runs: if course_run.status == CourseRunStatus.Published: # This will also update the course course_run.update_or_create_official_version() # Revert any Reviewed course runs back to Unpublished if changed: for course_run in course.course_runs.filter( status=CourseRunStatus.Reviewed): course_run.status = CourseRunStatus.Unpublished course_run.save() course_run.official_version.status = CourseRunStatus.Unpublished course_run.official_version.save() # hack to get the correctly-updated url slug into the response return_dict = {'url_slug': course.active_url_slug} return_dict.update(serializer.data) return Response(return_dict)
def create_run_helper(self, run_data, request=None): # These are both required to be part of self because when we call self.get_serializer, it tries # to set these two variables as part of the serializer context. When the endpoint is hit directly, # self.request should exist, but when this function is called from the Course POST endpoint in courses.py # we have to manually set these values. if not hasattr(self, 'request'): self.request = request # pylint: disable=attribute-defined-outside-init if not hasattr(self, 'format_kwarg'): self.format_kwarg = None # pylint: disable=attribute-defined-outside-init # Set a pacing default when creating (studio requires this to be set, even though discovery does not) run_data.setdefault('pacing_type', 'instructor_paced') # Guard against externally setting the draft state run_data.pop('draft', None) prices = run_data.pop('prices', {}) # Grab any existing course run for this course (we'll use it when talking to studio to form basis of rerun) course_key = run_data.get('course', None) # required field if not course_key: raise ValidationError({'course': ['This field is required.']}) # Before creating the serializer we need to ensure the course has draft rows as expected # The serializer will attempt to retrieve the draft version of the Course course = Course.objects.filter_drafts().get(key=course_key) course = ensure_draft_world(course) old_course_run_key = run_data.pop('rerun', None) serializer = self.get_serializer(data=run_data) serializer.is_valid(raise_exception=True) # Save run to database course_run = serializer.save(draft=True) course_run.update_or_create_seats(course_run.type, prices) # Set canonical course run if needed (done this way to match historical behavior - but shouldn't this be # updated *each* time we make a new run?) if not course.canonical_course_run: course.canonical_course_run = course_run course.save() elif not old_course_run_key: # On a rerun, only set the old course run key to the canonical key if a rerun hasn't been provided # This will prevent a breaking change if users of this endpoint don't choose to provide a key on rerun old_course_run_key = course.canonical_course_run.key if old_course_run_key: old_course_run = CourseRun.objects.filter_drafts().get(key=old_course_run_key) course_run.language = old_course_run.language course_run.min_effort = old_course_run.min_effort course_run.max_effort = old_course_run.max_effort course_run.weeks_to_complete = old_course_run.weeks_to_complete course_run.save() course_run.staff.set(old_course_run.staff.all()) course_run.transcript_languages.set(old_course_run.transcript_languages.all()) # And finally, push run to studio self.push_to_studio(self.request, course_run, create=True, old_course_run_key=old_course_run_key) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
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)