Skip to content

Commit

Permalink
Merge branch 'master' into jill/content-search-perms
Browse files Browse the repository at this point in the history
  • Loading branch information
pomegranited committed Apr 9, 2024
2 parents a28f3a9 + 41953bb commit ef24a88
Show file tree
Hide file tree
Showing 24 changed files with 28,985 additions and 33,492 deletions.
10 changes: 5 additions & 5 deletions .github/workflows/js-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [ ubuntu-20.04 ]
node-version: [ 16 ]
node-version: [ 18 ]
python-version: [ 3.8 ]

steps:
Expand All @@ -28,13 +28,13 @@ jobs:
node-version: ${{ matrix.node-version }}

- name: Setup npm
run: npm i -g npm@8.5.x
run: npm i -g npm@10.5.x

- name: Install Firefox 61.0
- name: Install Firefox 123.0
run: |
sudo apt-get purge firefox
wget "https://ftp.mozilla.org/pub/firefox/releases/61.0/linux-x86_64/en-US/firefox-61.0.tar.bz2"
tar -xjf firefox-61.0.tar.bz2
wget "https://ftp.mozilla.org/pub/firefox/releases/123.0/linux-x86_64/en-US/firefox-123.0.tar.bz2"
tar -xjf firefox-123.0.tar.bz2
sudo mv firefox /opt/firefox
sudo ln -s /opt/firefox/firefox /usr/bin/firefox
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lockfileversion-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ on:

jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
4 changes: 2 additions & 2 deletions .github/workflows/static-assets-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ jobs:
matrix:
os: [ ubuntu-20.04 ]
python-version: [ 3.8 ]
node-version: [ 16 ]
npm-version: [ 8.5.x ]
node-version: [ 18 ]
npm-version: [ 10.5.x ]
mongo-version: ["4.4", "7.0"]

services:
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ RUN pip install -r requirements/pip.txt
RUN pip install -r requirements/edx/base.txt

# Install node and npm
RUN nodeenv /edx/app/edxapp/nodeenv --node=16.14.0 --prebuilt
RUN npm install -g npm@8.5.x
RUN nodeenv /edx/app/edxapp/nodeenv --node=18.19.0 --prebuilt
RUN npm install -g npm@10.5.x

# This script is used by an npm post-install hook.
# We copy it into the image now so that it will be available when we run `npm install` in the next step.
Expand Down
45 changes: 44 additions & 1 deletion cms/djangoapps/api/v1/serializers/course_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -198,8 +199,50 @@ 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)
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 = 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
51 changes: 51 additions & 0 deletions cms/djangoapps/api/v1/tests/test_views/test_course_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
47 changes: 47 additions & 0 deletions cms/djangoapps/api/v1/views/course_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -90,3 +91,49 @@ 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=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()
serializer = self.get_serializer(new_course_run)
return Response({"message": "Course cloned successfully."}, status=status.HTTP_201_CREATED)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
)


class MessageValidation(serializers.Serializer):
"""
Serializer for representing XBlock error.
"""

text = serializers.CharField()
type = serializers.CharField()


class ChildAncestorSerializer(serializers.Serializer):
"""
Serializer for representing child blocks in the ancestor XBlock.
Expand Down Expand Up @@ -105,6 +114,8 @@ class ChildVerticalContainerSerializer(serializers.Serializer):
user_partition_info = serializers.DictField()
user_partitions = serializers.ListField()
actions = serializers.SerializerMethodField()
validation_messages = MessageValidation(many=True)
render_error = serializers.CharField()

def get_actions(self, obj): # pylint: disable=unused-argument
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
"""
Unit tests for the vertical block.
"""

from django.urls import reverse
from rest_framework import status
from edx_toggles.toggles.testutils import override_waffle_flag
from xblock.validation import ValidationMessage

from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.toggles import ENABLE_TAGGING_TAXONOMY_LIST_PAGE
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
from xmodule.partitions.partitions import (
ENROLLMENT_TRACK_PARTITION_ID,
Group,
UserPartition,
)
from xmodule.modulestore.django import (
modulestore,
) # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -96,6 +102,13 @@ def publish_item(self, store, item_location):
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
store.publish(item_location, ModuleStoreEnum.UserID.test)

def set_group_access(self, xblock, value):
"""
Sets group_access to specified value and calls update_item to persist the change.
"""
xblock.group_access = value
self.store.update_item(xblock, self.user.id)


class ContainerHandlerViewTest(BaseXBlockContainer):
"""
Expand Down Expand Up @@ -161,7 +174,7 @@ def test_children_content(self):
expected_user_partition_info = {
"selectable_partitions": [],
"selected_partition_index": -1,
"selected_groups_label": ""
"selected_groups_label": "",
}

expected_user_partitions = [
Expand All @@ -170,13 +183,8 @@ def test_children_content(self):
"name": "Enrollment Track Groups",
"scheme": "enrollment_track",
"groups": [
{
"id": 1,
"name": "Audit",
"selected": False,
"deleted": False
}
]
{"id": 1, "name": "Audit", "selected": False, "deleted": False}
],
}
]

Expand All @@ -190,16 +198,20 @@ def test_children_content(self):
"actions": {
"can_manage_tags": True,
},
"validation_messages": [],
"render_error": "",
},
{
"name": self.html_unit_second.display_name_with_default,
"block_id": str(self.html_unit_second.location),
"block_type": self.html_unit_second.location.block_type,
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,
"actions": {
"can_manage_tags": True,
},
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,
"validation_messages": [],
"render_error": "",
},
]
self.assertEqual(response.data["children"], expected_response)
Expand All @@ -224,3 +236,42 @@ def test_actions_with_turned_off_taxonomy_flag(self):
response = self.client.get(url)
for children in response.data["children"]:
self.assertFalse(children["actions"]["can_manage_tags"])

def test_validation_errors(self):
"""
Check that child has an error.
"""
self.course.user_partitions = [
UserPartition(
0,
"first_partition",
"Test Partition",
[Group("0", "alpha"), Group("1", "beta")],
),
]
self.store.update_item(self.course, self.user.id)

user_partition = self.course.user_partitions[0]
vertical = self.store.get_item(self.vertical.location)
html_unit_first = self.store.get_item(self.html_unit_first.location)

group_first = user_partition.groups[0]
group_second = user_partition.groups[1]

# Set access settings so html will contradict vertical
self.set_group_access(vertical, {user_partition.id: [group_second.id]})
self.set_group_access(html_unit_first, {user_partition.id: [group_first.id]})

# update vertical/html
vertical = self.store.get_item(self.vertical.location)
html_unit_first = self.store.get_item(self.html_unit_first.location)

url = self.get_reverse_url(self.vertical.location)
response = self.client.get(url)
children_response = response.data["children"]

# Verify that html_unit_first access settings contradict its parent's access settings.
self.assertEqual(children_response[0]["validation_messages"][0]["type"], ValidationMessage.ERROR)

# Verify that html_unit_second has no validation messages.
self.assertFalse(children_response[1]["validation_messages"])
Loading

0 comments on commit ef24a88

Please sign in to comment.