From 480e80359db0a908179b595e1a77d2bdf5e62720 Mon Sep 17 00:00:00 2001 From: Pooja Kulkarni Date: Fri, 17 Feb 2023 11:42:26 -0500 Subject: [PATCH 1/3] feat: add new endpoint for cloning course --- .../api/v1/serializers/course_runs.py | 33 ++++++++++++ .../v1/tests/test_views/test_course_runs.py | 51 +++++++++++++++++++ cms/djangoapps/api/v1/views/course_runs.py | 8 +++ 3 files changed, 92 insertions(+) diff --git a/cms/djangoapps/api/v1/serializers/course_runs.py b/cms/djangoapps/api/v1/serializers/course_runs.py index cbd4d09e2181..6bbbce96dd42 100644 --- a/cms/djangoapps/api/v1/serializers/course_runs.py +++ b/cms/djangoapps/api/v1/serializers/course_runs.py @@ -5,6 +5,7 @@ from django.db import transaction from django.utils.translation import gettext_lazy as _ from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from rest_framework import serializers from rest_framework.fields import empty @@ -203,3 +204,35 @@ def update(self, instance, validated_data): course_run = get_course_and_check_access(new_course_run_key, user) self.update_team(course_run, team) return course_run + + +class CourseCloneSerializer(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring + source_course_id = serializers.CharField() + destination_course_id = serializers.CharField() + + def validate(self, attrs): + source_course_id = attrs.get('source_course_id') + destination_course_id = attrs.get('destination_course_id') + store = modulestore() + source_key = CourseKey.from_string(source_course_id) + dest_key = CourseKey.from_string(destination_course_id) + + # Check if the source course exists + if not store.has_course(source_key): + raise serializers.ValidationError('Source course does not exist.') + + # Check if the destination course already exists + if store.has_course(dest_key): + raise serializers.ValidationError('Destination course already exists.') + return attrs + + def create(self, validated_data): + source_course_id = validated_data.get('source_course_id') + destination_course_id = validated_data.get('destination_course_id') + user_id = self.context['request'].user.id + store = modulestore() + source_key = CourseKey.from_string(source_course_id) + dest_key = CourseKey.from_string(destination_course_id) + with store.default_store('split'): + new_course = store.clone_course(source_key, dest_key, user_id) + return new_course diff --git a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py index 49589a473878..8366ef72941e 100644 --- a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py +++ b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py @@ -402,3 +402,54 @@ def test_rerun_invalid_number(self): assert response.data == {'non_field_errors': [ 'Invalid key supplied. Ensure there are no special characters in the Course Number.' ]} + + def test_clone_course(self): + course = CourseFactory() + url = reverse('api:v1:course_run-clone') + data = { + 'source_course_id': str(course.id), + 'destination_course_id': 'course-v1:destination+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 201 + self.assertEqual(response.data, {"message": "Course cloned successfully."}) + + def test_clone_course_with_missing_source_id(self): + url = reverse('api:v1:course_run-clone') + data = { + 'destination_course_id': 'course-v1:destination+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + self.assertEqual(response.data, {'source_course_id': ['This field is required.']}) + + def test_clone_course_with_missing_dest_id(self): + url = reverse('api:v1:course_run-clone') + data = { + 'source_course_id': 'course-v1:source+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + self.assertEqual(response.data, {'destination_course_id': ['This field is required.']}) + + def test_clone_course_with_nonexistent_source_course(self): + url = reverse('api:v1:course_run-clone') + data = { + 'source_course_id': 'course-v1:nonexistent+source+course_id', + 'destination_course_id': 'course-v1:destination+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + assert str(response.data.get('non_field_errors')[0]) == 'Source course does not exist.' + + def test_clone_course_with_existing_dest_course(self): + url = reverse('api:v1:course_run-clone') + course = CourseFactory() + existing_dest_course = CourseFactory() + data = { + 'source_course_id': str(course.id), + 'destination_course_id': str(existing_dest_course.id), + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + assert str(response.data.get('non_field_errors')[0]) == 'Destination course already exists.' diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index d7d62172759f..485996b8233c 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -11,6 +11,7 @@ from cms.djangoapps.contentstore.views.course import _accessible_courses_iter, get_course_and_check_access from ..serializers.course_runs import ( + CourseCloneSerializer, CourseRunCreateSerializer, CourseRunImageSerializer, CourseRunRerunSerializer, @@ -90,3 +91,10 @@ def rerun(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=miss new_course_run = serializer.save() serializer = self.get_serializer(new_course_run) return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['post']) + def clone(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument + serializer = CourseCloneSerializer(data=request.data, context=self.get_serializer_context()) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response({"message": "Course cloned successfully."}, status=status.HTTP_201_CREATED) From 0b0f109eab3392ecbf76f877a80bdfe86367e919 Mon Sep 17 00:00:00 2001 From: Maxim Beder Date: Sun, 4 Feb 2024 21:50:42 +0100 Subject: [PATCH 2/3] refactor: use rerun_course for course cloning --- .../api/v1/serializers/course_runs.py | 26 +++++++++++++------ cms/djangoapps/api/v1/views/course_runs.py | 3 ++- cms/djangoapps/contentstore/views/course.py | 6 +++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/cms/djangoapps/api/v1/serializers/course_runs.py b/cms/djangoapps/api/v1/serializers/course_runs.py index 6bbbce96dd42..e1ee6b743034 100644 --- a/cms/djangoapps/api/v1/serializers/course_runs.py +++ b/cms/djangoapps/api/v1/serializers/course_runs.py @@ -199,7 +199,9 @@ def update(self, instance, validated_data): 'display_name': instance.display_name } fields.update(validated_data) - new_course_run_key = rerun_course(user, course_run_key, course_run_key.org, number, run, fields, False) + new_course_run_key = rerun_course( + user, course_run_key, course_run_key.org, number, run, fields, background=False, + ) course_run = get_course_and_check_access(new_course_run_key, user) self.update_team(course_run, team) @@ -229,10 +231,18 @@ def validate(self, attrs): def create(self, validated_data): source_course_id = validated_data.get('source_course_id') destination_course_id = validated_data.get('destination_course_id') - user_id = self.context['request'].user.id - store = modulestore() - source_key = CourseKey.from_string(source_course_id) - dest_key = CourseKey.from_string(destination_course_id) - with store.default_store('split'): - new_course = store.clone_course(source_key, dest_key, user_id) - return new_course + user = self.context['request'].user + source_course_key = CourseKey.from_string(source_course_id) + destination_course_key = CourseKey.from_string(destination_course_id) + source_course_run = get_course_and_check_access(source_course_key, user) + fields = { + 'display_name': source_course_run.display_name, + } + + destination_course_run_key = rerun_course( + user, source_course_key, destination_course_key.org, destination_course_key.course, + destination_course_key.run, fields, background=False, + ) + + destination_course_run = get_course_and_check_access(destination_course_run_key, user) + return destination_course_run diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index 485996b8233c..443ce315010e 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -96,5 +96,6 @@ def rerun(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=miss def clone(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument serializer = CourseCloneSerializer(data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) - serializer.save() + new_course_run = serializer.save() + serializer = self.get_serializer(new_course_run) return Response({"message": "Course cloned successfully."}, status=status.HTTP_201_CREATED) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 4777c36be603..b56c989b411e 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1029,6 +1029,12 @@ def rerun_course(user, source_course_key, org, number, run, fields, background=T if store.has_course(destination_course_key, ignore_case=True): raise DuplicateCourseError(source_course_key, destination_course_key) + # if org or name of source course don't match the destination course, + # verify user has access to the destination course + if source_course_key.org != destination_course_key.org or source_course_key.course != destination_course_key.course: + if not has_studio_write_access(user, destination_course_key): + raise PermissionDenied() + # Make sure user has instructor and staff access to the destination course # so the user can see the updated status for that course add_instructor(destination_course_key, user, user) From 756770633c9cfd5cbe746a140844ca19ea080fdf Mon Sep 17 00:00:00 2001 From: Maxim Beder Date: Mon, 8 Apr 2024 10:48:33 +0200 Subject: [PATCH 3/3] docs: add docstring for course clone endpoint --- cms/djangoapps/api/v1/views/course_runs.py | 40 +++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index 443ce315010e..b405207bd9c6 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -93,7 +93,45 @@ def rerun(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=miss return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=False, methods=['post']) - def clone(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument + def clone(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + """ + **Use Case** + + This endpoint can be used for course cloning. + + Unlike reruns, cloning a course allows creating a copy of an existing + course under a different organization name and with a different course + name. + + **Example Request** + + POST /api/v1/course_runs/clone/ { + "source_course_id": "course-v1:edX+DemoX+Demo_Course", + "destination_course_id": "course-v1:newOrg+newDemoX+Demo_Course_Clone" + } + + **POST Parameters** + + * source_course_id: a full course id of the course that will be + cloned. Has to be an id of an existing course. + * destination_course_id: a full course id of the destination + course. The organization, course name and course run of the + new course will be determined from the provided id. Has to be + an id of a course that doesn't exist yet. + + **Response Values** + + If the request parameters are valid and a course has been cloned + succesfully, an HTTP 201 "Created" response is returned. + + If source course id and/or destination course id are invalid, or + source course doesn't exist, or destination course already exist, + an HTTP 400 "Bad Request" response is returned. + + If the user that is making the request doesn't have the access to + either of the courses, an HTTP 401 "Unauthorized" response is + returned. + """ serializer = CourseCloneSerializer(data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) new_course_run = serializer.save()