Commit 0c637cdc by Giovanni Di Milia

Added extra field to CCX model for Course Models

REST APIs modified
parent e3ddb02c
...@@ -17,6 +17,7 @@ class CCXCourseSerializer(serializers.ModelSerializer): ...@@ -17,6 +17,7 @@ class CCXCourseSerializer(serializers.ModelSerializer):
start = serializers.CharField(allow_blank=True) start = serializers.CharField(allow_blank=True)
due = serializers.CharField(allow_blank=True) due = serializers.CharField(allow_blank=True)
max_students_allowed = serializers.IntegerField(source='max_student_enrollments_allowed') max_students_allowed = serializers.IntegerField(source='max_student_enrollments_allowed')
course_modules = serializers.SerializerMethodField()
class Meta(object): class Meta(object):
model = CustomCourseForEdX model = CustomCourseForEdX
...@@ -28,6 +29,7 @@ class CCXCourseSerializer(serializers.ModelSerializer): ...@@ -28,6 +29,7 @@ class CCXCourseSerializer(serializers.ModelSerializer):
"start", "start",
"due", "due",
"max_students_allowed", "max_students_allowed",
"course_modules",
) )
read_only_fields = ( read_only_fields = (
"ccx_course_id", "ccx_course_id",
...@@ -42,3 +44,10 @@ class CCXCourseSerializer(serializers.ModelSerializer): ...@@ -42,3 +44,10 @@ class CCXCourseSerializer(serializers.ModelSerializer):
Getter for the CCX Course ID Getter for the CCX Course ID
""" """
return unicode(CCXLocator.from_course_locator(obj.course.id, obj.id)) return unicode(CCXLocator.from_course_locator(obj.course.id, obj.id))
@staticmethod
def get_course_modules(obj):
"""
Getter for the Course Modules. The list is stored in a compressed field.
"""
return obj.structure or []
...@@ -38,6 +38,7 @@ from lms.djangoapps.ccx.api.v0 import views ...@@ -38,6 +38,7 @@ from lms.djangoapps.ccx.api.v0 import views
from lms.djangoapps.ccx.models import CcxFieldOverride, CustomCourseForEdX from lms.djangoapps.ccx.models import CcxFieldOverride, CustomCourseForEdX
from lms.djangoapps.ccx.overrides import override_field_for_ccx from lms.djangoapps.ccx.overrides import override_field_for_ccx
from lms.djangoapps.ccx.tests.utils import CcxTestCase from lms.djangoapps.ccx.tests.utils import CcxTestCase
from lms.djangoapps.ccx.utils import get_course_chapters
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from student.roles import ( from student.roles import (
CourseInstructorRole, CourseInstructorRole,
...@@ -85,6 +86,8 @@ class CcxRestApiTest(CcxTestCase, APITestCase): ...@@ -85,6 +86,8 @@ class CcxRestApiTest(CcxTestCase, APITestCase):
self.course.enable_ccx = True self.course.enable_ccx = True
self.mstore.update_item(self.course, self.coach.id) self.mstore.update_item(self.course, self.coach.id)
self.auth = self.get_auth_token() self.auth = self.get_auth_token()
# making the master course chapters easily available
self.master_course_chapters = get_course_chapters(self.master_course_key)
def get_auth_token(self): def get_auth_token(self):
""" """
...@@ -465,11 +468,38 @@ class CcxListTest(CcxRestApiTest): ...@@ -465,11 +468,38 @@ class CcxListTest(CcxRestApiTest):
}, },
{'max_students_allowed': 'invalid_max_students_allowed'} {'max_students_allowed': 'invalid_max_students_allowed'}
), ),
(
{
'max_students_allowed': 10,
'display_name': 'CCX Title',
'coach_email': 'email@test.com',
'course_modules': {'foo': 'bar'}
},
{'course_modules': 'invalid_course_module_list'}
),
(
{
'max_students_allowed': 10,
'display_name': 'CCX Title',
'coach_email': 'email@test.com',
'course_modules': 'block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_1'
},
{'course_modules': 'invalid_course_module_list'}
),
(
{
'max_students_allowed': 10,
'display_name': 'CCX Title',
'coach_email': 'email@test.com',
'course_modules': ['foo', 'bar']
},
{'course_modules': 'invalid_course_module_keys'}
),
) )
@ddt.unpack @ddt.unpack
def test_post_list_wrong_input_data(self, data, expected_errors): def test_post_list_wrong_input_data(self, data, expected_errors):
""" """
Test for various post requests with wrong master course string Test for various post requests with wrong input data
""" """
# add the master_course_key_str to the request data # add the master_course_key_str to the request data
data['master_course_id'] = self.master_course_key_str data['master_course_id'] = self.master_course_key_str
...@@ -489,6 +519,40 @@ class CcxListTest(CcxRestApiTest): ...@@ -489,6 +519,40 @@ class CcxListTest(CcxRestApiTest):
resp = self.client.post(self.list_url, data, format='json', HTTP_AUTHORIZATION=self.auth) resp = self.client.post(self.list_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.expect_error(status.HTTP_404_NOT_FOUND, 'coach_user_does_not_exist', resp) self.expect_error(status.HTTP_404_NOT_FOUND, 'coach_user_does_not_exist', resp)
def test_post_list_wrong_modules(self):
"""
Specific test for the case when the input data is valid but the
course modules do not belong to the master course
"""
data = {
'master_course_id': self.master_course_key_str,
'max_students_allowed': 111,
'display_name': 'CCX Title',
'coach_email': self.coach.email,
'course_modules': [
'block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_foo',
'block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_bar'
]
}
resp = self.client.post(self.list_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.expect_error(status.HTTP_400_BAD_REQUEST, 'course_module_list_not_belonging_to_master_course', resp)
def test_post_list_mixed_wrong_and_valid_modules(self):
"""
Specific test for the case when the input data is valid but some of
the course modules do not belong to the master course
"""
modules = self.master_course_chapters[0:1] + ['block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_foo']
data = {
'master_course_id': self.master_course_key_str,
'max_students_allowed': 111,
'display_name': 'CCX Title',
'coach_email': self.coach.email,
'course_modules': modules
}
resp = self.client.post(self.list_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.expect_error(status.HTTP_400_BAD_REQUEST, 'course_module_list_not_belonging_to_master_course', resp)
def test_post_list(self): def test_post_list(self):
""" """
Test the creation of a CCX Test the creation of a CCX
...@@ -498,7 +562,8 @@ class CcxListTest(CcxRestApiTest): ...@@ -498,7 +562,8 @@ class CcxListTest(CcxRestApiTest):
'master_course_id': self.master_course_key_str, 'master_course_id': self.master_course_key_str,
'max_students_allowed': 111, 'max_students_allowed': 111,
'display_name': 'CCX Test Title', 'display_name': 'CCX Test Title',
'coach_email': self.coach.email 'coach_email': self.coach.email,
'course_modules': self.master_course_chapters[0:1]
} }
resp = self.client.post(self.list_url, data, format='json', HTTP_AUTHORIZATION=self.auth) resp = self.client.post(self.list_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED) self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
...@@ -525,6 +590,23 @@ class CcxListTest(CcxRestApiTest): ...@@ -525,6 +590,23 @@ class CcxListTest(CcxRestApiTest):
self.assertEqual(len(outbox), 1) self.assertEqual(len(outbox), 1)
self.assertIn(self.coach.email, outbox[0].recipients()) # pylint: disable=no-member self.assertIn(self.coach.email, outbox[0].recipients()) # pylint: disable=no-member
def test_post_list_duplicated_modules(self):
"""
Test the creation of a CCX, but with duplicated modules
"""
chapters = self.master_course_chapters[0:1]
duplicated_chapters = chapters * 3
data = {
'master_course_id': self.master_course_key_str,
'max_students_allowed': 111,
'display_name': 'CCX Test Title',
'coach_email': self.coach.email,
'course_modules': duplicated_chapters
}
resp = self.client.post(self.list_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(resp.data.get('course_modules'), chapters) # pylint: disable=no-member
@attr('shard_1') @attr('shard_1')
@ddt.ddt @ddt.ddt
...@@ -554,6 +636,8 @@ class CcxDetailTest(CcxRestApiTest): ...@@ -554,6 +636,8 @@ class CcxDetailTest(CcxRestApiTest):
creation of ccx courses creation of ccx courses
""" """
ccx = super(CcxDetailTest, self).make_ccx(max_students_allowed=max_students_allowed) ccx = super(CcxDetailTest, self).make_ccx(max_students_allowed=max_students_allowed)
ccx.structure_json = json.dumps(self.master_course_chapters)
ccx.save()
today = datetime.datetime.today() today = datetime.datetime.today()
start = today.replace(tzinfo=pytz.UTC) start = today.replace(tzinfo=pytz.UTC)
...@@ -745,6 +829,7 @@ class CcxDetailTest(CcxRestApiTest): ...@@ -745,6 +829,7 @@ class CcxDetailTest(CcxRestApiTest):
) )
self.assertEqual(resp.data.get('coach_email'), self.ccx.coach.email) # pylint: disable=no-member self.assertEqual(resp.data.get('coach_email'), self.ccx.coach.email) # pylint: disable=no-member
self.assertEqual(resp.data.get('master_course_id'), unicode(self.ccx.course_id)) # pylint: disable=no-member self.assertEqual(resp.data.get('master_course_id'), unicode(self.ccx.course_id)) # pylint: disable=no-member
self.assertEqual(resp.data.get('course_modules'), self.master_course_chapters) # pylint: disable=no-member
def test_delete_detail(self): def test_delete_detail(self):
""" """
...@@ -787,29 +872,29 @@ class CcxDetailTest(CcxRestApiTest): ...@@ -787,29 +872,29 @@ class CcxDetailTest(CcxRestApiTest):
} }
), ),
( (
{ {'coach_email': 'this is not an email@test.com'},
'max_students_allowed': 10,
'display_name': 'CCX Title',
'coach_email': 'this is not an email@test.com'
},
{'coach_email': 'invalid_coach_email'} {'coach_email': 'invalid_coach_email'}
), ),
( (
{ {'display_name': ''},
'max_students_allowed': 10,
'display_name': '',
'coach_email': 'email@test.com'
},
{'display_name': 'invalid_display_name'} {'display_name': 'invalid_display_name'}
), ),
( (
{ {'max_students_allowed': 'a'},
'max_students_allowed': 'a',
'display_name': 'CCX Title',
'coach_email': 'email@test.com'
},
{'max_students_allowed': 'invalid_max_students_allowed'} {'max_students_allowed': 'invalid_max_students_allowed'}
), ),
(
{'course_modules': {'foo': 'bar'}},
{'course_modules': 'invalid_course_module_list'}
),
(
{'course_modules': 'block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_1'},
{'course_modules': 'invalid_course_module_list'}
),
(
{'course_modules': ['foo', 'bar']},
{'course_modules': 'invalid_course_module_keys'}
),
) )
@ddt.unpack @ddt.unpack
def test_patch_detail_wrong_input_data(self, data, expected_errors): def test_patch_detail_wrong_input_data(self, data, expected_errors):
...@@ -826,12 +911,14 @@ class CcxDetailTest(CcxRestApiTest): ...@@ -826,12 +911,14 @@ class CcxDetailTest(CcxRestApiTest):
display_name = self.ccx.display_name display_name = self.ccx.display_name
max_students_allowed = self.ccx.max_student_enrollments_allowed # pylint: disable=no-member max_students_allowed = self.ccx.max_student_enrollments_allowed # pylint: disable=no-member
coach_email = self.ccx.coach.email # pylint: disable=no-member coach_email = self.ccx.coach.email # pylint: disable=no-member
ccx_structure = self.ccx.structure # pylint: disable=no-member
resp = self.client.patch(self.detail_url, {}, format='json', HTTP_AUTHORIZATION=self.auth) resp = self.client.patch(self.detail_url, {}, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
ccx = CustomCourseForEdX.objects.get(id=self.ccx.id) ccx = CustomCourseForEdX.objects.get(id=self.ccx.id)
self.assertEqual(display_name, ccx.display_name) self.assertEqual(display_name, ccx.display_name)
self.assertEqual(max_students_allowed, ccx.max_student_enrollments_allowed) self.assertEqual(max_students_allowed, ccx.max_student_enrollments_allowed)
self.assertEqual(coach_email, ccx.coach.email) self.assertEqual(coach_email, ccx.coach.email)
self.assertEqual(ccx_structure, ccx.structure)
def test_patch_detail_coach_does_not_exist(self): def test_patch_detail_coach_does_not_exist(self):
""" """
...@@ -845,6 +932,32 @@ class CcxDetailTest(CcxRestApiTest): ...@@ -845,6 +932,32 @@ class CcxDetailTest(CcxRestApiTest):
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth) resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.expect_error(status.HTTP_404_NOT_FOUND, 'coach_user_does_not_exist', resp) self.expect_error(status.HTTP_404_NOT_FOUND, 'coach_user_does_not_exist', resp)
def test_patch_detail_wrong_modules(self):
"""
Specific test for the case when the input data is valid but the
course modules do not belong to the master course
"""
data = {
'course_modules': [
'block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_foo',
'block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_bar'
]
}
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.expect_error(status.HTTP_400_BAD_REQUEST, 'course_module_list_not_belonging_to_master_course', resp)
def test_patch_detail_mixed_wrong_and_valid_modules(self):
"""
Specific test for the case when the input data is valid but some of
the course modules do not belong to the master course
"""
modules = self.master_course_chapters[0:1] + ['block-v1:org.0+course_0+Run_0+type@chapter+block@chapter_foo']
data = {
'course_modules': modules
}
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.expect_error(status.HTTP_400_BAD_REQUEST, 'course_module_list_not_belonging_to_master_course', resp)
def test_patch_detail(self): def test_patch_detail(self):
""" """
Test for successful patch Test for successful patch
...@@ -874,3 +987,38 @@ class CcxDetailTest(CcxRestApiTest): ...@@ -874,3 +987,38 @@ class CcxDetailTest(CcxRestApiTest):
# check that an email has been sent to the coach # check that an email has been sent to the coach
self.assertEqual(len(outbox), 1) self.assertEqual(len(outbox), 1)
self.assertIn(new_coach.email, outbox[0].recipients()) # pylint: disable=no-member self.assertIn(new_coach.email, outbox[0].recipients()) # pylint: disable=no-member
def test_patch_detail_modules(self):
"""
Specific test for successful patch of the course modules
"""
data = {'course_modules': self.master_course_chapters[0:1]}
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
ccx_from_db = CustomCourseForEdX.objects.get(id=self.ccx.id)
self.assertEqual(ccx_from_db.structure, data['course_modules'])
data = {'course_modules': []}
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
ccx_from_db = CustomCourseForEdX.objects.get(id=self.ccx.id)
self.assertEqual(ccx_from_db.structure, [])
data = {'course_modules': self.master_course_chapters}
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
ccx_from_db = CustomCourseForEdX.objects.get(id=self.ccx.id)
self.assertEqual(ccx_from_db.structure, self.master_course_chapters)
data = {'course_modules': None}
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
ccx_from_db = CustomCourseForEdX.objects.get(id=self.ccx.id)
self.assertEqual(ccx_from_db.structure, None)
chapters = self.master_course_chapters[0:1]
data = {'course_modules': chapters * 3}
resp = self.client.patch(self.detail_url, data, format='json', HTTP_AUTHORIZATION=self.auth)
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
ccx_from_db = CustomCourseForEdX.objects.get(id=self.ccx.id)
self.assertEqual(ccx_from_db.structure, chapters)
""" API v0 views. """ """ API v0 views. """
import datetime import datetime
import json
import logging import logging
import pytz import pytz
...@@ -21,7 +22,7 @@ from instructor.enrollment import ( ...@@ -21,7 +22,7 @@ from instructor.enrollment import (
get_email_params, get_email_params,
) )
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.lib.api import permissions from openedx.core.lib.api import permissions
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -35,6 +36,7 @@ from lms.djangoapps.ccx.overrides import ( ...@@ -35,6 +36,7 @@ from lms.djangoapps.ccx.overrides import (
from lms.djangoapps.ccx.utils import ( from lms.djangoapps.ccx.utils import (
assign_coach_role_to_ccx, assign_coach_role_to_ccx,
is_email, is_email,
get_course_chapters,
) )
from .paginators import CCXAPIPagination from .paginators import CCXAPIPagination
from .serializers import CCXCourseSerializer from .serializers import CCXCourseSerializer
...@@ -156,9 +158,46 @@ def get_valid_input(request_data, ignore_missing=False): ...@@ -156,9 +158,46 @@ def get_valid_input(request_data, ignore_missing=False):
field_errors['max_students_allowed'] = {'error_code': 'invalid_max_students_allowed'} field_errors['max_students_allowed'] = {'error_code': 'invalid_max_students_allowed'}
elif 'max_students_allowed' in request_data: elif 'max_students_allowed' in request_data:
field_errors['max_students_allowed'] = {'error_code': 'null_field_max_students_allowed'} field_errors['max_students_allowed'] = {'error_code': 'null_field_max_students_allowed'}
course_modules = request_data.get('course_modules')
if course_modules is not None:
if isinstance(course_modules, list):
# de-duplicate list of modules
course_modules = list(set(course_modules))
for course_module_id in course_modules:
try:
UsageKey.from_string(course_module_id)
except InvalidKeyError:
field_errors['course_modules'] = {'error_code': 'invalid_course_module_keys'}
break
else:
valid_input['course_modules'] = course_modules
else:
field_errors['course_modules'] = {'error_code': 'invalid_course_module_list'}
elif 'course_modules' in request_data:
# case if the user actually passed null as input
valid_input['course_modules'] = None
return valid_input, field_errors return valid_input, field_errors
def valid_course_modules(course_module_list, master_course_key):
"""
Function to validate that each element in the course_module_list belongs
to the master course structure.
Args:
course_module_list (list): A list of strings representing Block Usage Keys
master_course_key (CourseKey): An object representing the master course key id
Returns:
bool: whether or not all the course module strings belong to the master course
"""
course_chapters = get_course_chapters(master_course_key)
if course_chapters is None:
return False
return set(course_module_list).intersection(set(course_chapters)) == set(course_module_list)
def make_user_coach(user, master_course_key): def make_user_coach(user, master_course_key):
""" """
Makes an user coach on the master course. Makes an user coach on the master course.
...@@ -190,7 +229,12 @@ class CCXListView(GenericAPIView): ...@@ -190,7 +229,12 @@ class CCXListView(GenericAPIView):
"master_course_id": "course-v1:Organization+EX101+RUN-FALL2099", "master_course_id": "course-v1:Organization+EX101+RUN-FALL2099",
"display_name": "CCX example title", "display_name": "CCX example title",
"coach_email": "john@example.com", "coach_email": "john@example.com",
"max_students_allowed": 123 "max_students_allowed": 123,
"course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
} }
...@@ -220,6 +264,8 @@ class CCXListView(GenericAPIView): ...@@ -220,6 +264,8 @@ class CCXListView(GenericAPIView):
* max_students_allowed: An integer representing he maximum number of students that * max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course. can be enrolled in the CCX Course.
* course_modules: Optional. A list of course modules id keys.
**GET Response Values** **GET Response Values**
If the request for information about the course is successful, an HTTP 200 "OK" response If the request for information about the course is successful, an HTTP 200 "OK" response
...@@ -242,6 +288,8 @@ class CCXListView(GenericAPIView): ...@@ -242,6 +288,8 @@ class CCXListView(GenericAPIView):
* max_students_allowed: An integer representing he maximum number of students that * max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course. can be enrolled in the CCX Course.
* course_modules: A list of course modules id keys.
* count: An integer representing the total number of records that matched the request parameters. * count: An integer representing the total number of records that matched the request parameters.
* next: A string representing the URL where to retrieve the next page of results. This can be `null` * next: A string representing the URL where to retrieve the next page of results. This can be `null`
...@@ -263,7 +311,12 @@ class CCXListView(GenericAPIView): ...@@ -263,7 +311,12 @@ class CCXListView(GenericAPIView):
"coach_email": "john@example.com", "coach_email": "john@example.com",
"start": "2019-01-01", "start": "2019-01-01",
"due": "2019-06-01", "due": "2019-06-01",
"max_students_allowed": 123 "max_students_allowed": 123,
"course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
}, },
{ ... } { ... }
} }
...@@ -289,6 +342,8 @@ class CCXListView(GenericAPIView): ...@@ -289,6 +342,8 @@ class CCXListView(GenericAPIView):
* max_students_allowed: An integer representing he maximum number of students that * max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course. can be enrolled in the CCX Course.
* course_modules: A list of course modules id keys.
**Example POST Response** **Example POST Response**
{ {
...@@ -297,8 +352,13 @@ class CCXListView(GenericAPIView): ...@@ -297,8 +352,13 @@ class CCXListView(GenericAPIView):
"coach_email": "john@example.com", "coach_email": "john@example.com",
"start": "2019-01-01", "start": "2019-01-01",
"due": "2019-06-01", "due": "2019-06-01",
"max_students_allowed": 123 "max_students_allowed": 123,
} "course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
}
""" """
authentication_classes = (OAuth2Authentication, SessionAuthentication,) authentication_classes = (OAuth2Authentication, SessionAuthentication,)
permission_classes = (IsAuthenticated, permissions.IsMasterCourseStaffInstructor) permission_classes = (IsAuthenticated, permissions.IsMasterCourseStaffInstructor)
...@@ -383,11 +443,24 @@ class CCXListView(GenericAPIView): ...@@ -383,11 +443,24 @@ class CCXListView(GenericAPIView):
} }
) )
if valid_input.get('course_modules'):
if not valid_course_modules(valid_input['course_modules'], master_course_key):
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
'error_code': 'course_module_list_not_belonging_to_master_course'
}
)
# prepare the course_modules to be stored in a json stringified field
course_modules_json = json.dumps(valid_input.get('course_modules'))
with transaction.atomic(): with transaction.atomic():
ccx_course_object = CustomCourseForEdX( ccx_course_object = CustomCourseForEdX(
course_id=master_course_object.id, course_id=master_course_object.id,
coach=coach, coach=coach,
display_name=valid_input['display_name']) display_name=valid_input['display_name'],
structure_json=course_modules_json
)
ccx_course_object.save() ccx_course_object.save()
# Make sure start/due are overridden for entire course # Make sure start/due are overridden for entire course
...@@ -459,7 +532,12 @@ class CCXDetailView(GenericAPIView): ...@@ -459,7 +532,12 @@ class CCXDetailView(GenericAPIView):
"display_name": "CCX example title modified", "display_name": "CCX example title modified",
"coach_email": "joe@example.com", "coach_email": "joe@example.com",
"max_students_allowed": 111 "max_students_allowed": 111,
"course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
} }
DELETE /api/ccx/v0/ccx/{ccx_course_id} DELETE /api/ccx/v0/ccx/{ccx_course_id}
...@@ -483,6 +561,8 @@ class CCXDetailView(GenericAPIView): ...@@ -483,6 +561,8 @@ class CCXDetailView(GenericAPIView):
* max_students_allowed: Optional. An integer representing he maximum number of students that * max_students_allowed: Optional. An integer representing he maximum number of students that
can be enrolled in the CCX Course. can be enrolled in the CCX Course.
* course_modules: Optional. A list of course modules id keys.
**GET Response Values** **GET Response Values**
If the request for information about the CCX course is successful, an HTTP 200 "OK" response If the request for information about the CCX course is successful, an HTTP 200 "OK" response
...@@ -503,6 +583,8 @@ class CCXDetailView(GenericAPIView): ...@@ -503,6 +583,8 @@ class CCXDetailView(GenericAPIView):
* max_students_allowed: An integer representing he maximum number of students that * max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course. can be enrolled in the CCX Course.
* course_modules: A list of course modules id keys.
**PATCH and DELETE Response Values** **PATCH and DELETE Response Values**
If the request for modification or deletion of a CCX course is successful, an HTTP 204 "No Content" If the request for modification or deletion of a CCX course is successful, an HTTP 204 "No Content"
...@@ -606,6 +688,9 @@ class CCXDetailView(GenericAPIView): ...@@ -606,6 +688,9 @@ class CCXDetailView(GenericAPIView):
} }
) )
# get the master course key and master course object
master_course_object, master_course_key, _, _ = get_valid_course(unicode(ccx_course_object.course_id))
with transaction.atomic(): with transaction.atomic():
# update the display name # update the display name
if 'display_name' in valid_input: if 'display_name' in valid_input:
...@@ -625,6 +710,17 @@ class CCXDetailView(GenericAPIView): ...@@ -625,6 +710,17 @@ class CCXDetailView(GenericAPIView):
if ccx_course_object.coach.id != coach.id: if ccx_course_object.coach.id != coach.id:
old_coach = ccx_course_object.coach old_coach = ccx_course_object.coach
ccx_course_object.coach = coach ccx_course_object.coach = coach
if 'course_modules' in valid_input:
if valid_input.get('course_modules'):
if not valid_course_modules(valid_input['course_modules'], master_course_key):
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
'error_code': 'course_module_list_not_belonging_to_master_course'
}
)
# course_modules to be stored in a json stringified field
ccx_course_object.structure_json = json.dumps(valid_input.get('course_modules'))
ccx_course_object.save() ccx_course_object.save()
# update the overridden field for the maximum amount of students # update the overridden field for the maximum amount of students
if 'max_students_allowed' in valid_input: if 'max_students_allowed' in valid_input:
...@@ -636,8 +732,6 @@ class CCXDetailView(GenericAPIView): ...@@ -636,8 +732,6 @@ class CCXDetailView(GenericAPIView):
) )
# if the coach has changed, update the permissions # if the coach has changed, update the permissions
if old_coach is not None: if old_coach is not None:
# get the master course key and master course object
master_course_object, master_course_key, _, _ = get_valid_course(unicode(ccx_course_object.course_id))
# make the new ccx coach a coach on the master course # make the new ccx coach a coach on the master course
make_user_coach(coach, master_course_key) make_user_coach(coach, master_course_key)
# enroll the coach in the ccx # enroll the coach in the ccx
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ccx', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customcourseforedx',
name='structure_json',
field=models.TextField(null=True, verbose_name=b'Structure JSON', blank=True),
),
]
""" """
Models for the custom course feature Models for the custom course feature
""" """
from datetime import datetime import json
import logging import logging
from datetime import datetime
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
...@@ -24,6 +25,9 @@ class CustomCourseForEdX(models.Model): ...@@ -24,6 +25,9 @@ class CustomCourseForEdX(models.Model):
course_id = CourseKeyField(max_length=255, db_index=True) course_id = CourseKeyField(max_length=255, db_index=True)
display_name = models.CharField(max_length=255) display_name = models.CharField(max_length=255)
coach = models.ForeignKey(User, db_index=True) coach = models.ForeignKey(User, db_index=True)
# if not empty, this field contains a json serialized list of
# the master course modules
structure_json = models.TextField(verbose_name='Structure JSON', blank=True, null=True)
class Meta(object): class Meta(object):
app_label = 'ccx' app_label = 'ccx'
...@@ -107,6 +111,15 @@ class CustomCourseForEdX(models.Model): ...@@ -107,6 +111,15 @@ class CustomCourseForEdX(models.Model):
value += u' UTC' value += u' UTC'
return value return value
@property
def structure(self):
"""
Deserializes a course structure JSON object
"""
if self.structure_json:
return json.loads(self.structure_json)
return None
class CcxFieldOverride(models.Model): class CcxFieldOverride(models.Model):
""" """
......
""" """
tests for the models tests for the models
""" """
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.timezone import UTC from django.utils.timezone import UTC
from mock import patch from mock import patch
...@@ -30,11 +31,11 @@ class TestCCX(ModuleStoreTestCase): ...@@ -30,11 +31,11 @@ class TestCCX(ModuleStoreTestCase):
def setUp(self): def setUp(self):
"""common setup for all tests""" """common setup for all tests"""
super(TestCCX, self).setUp() super(TestCCX, self).setUp()
self.course = course = CourseFactory.create() self.course = CourseFactory.create()
coach = AdminFactory.create() self.coach = AdminFactory.create()
role = CourseCcxCoachRole(course.id) role = CourseCcxCoachRole(self.course.id)
role.add_users(coach) role.add_users(self.coach)
self.ccx = CcxFactory(course_id=course.id, coach=coach) self.ccx = CcxFactory(course_id=self.course.id, coach=self.coach)
def set_ccx_override(self, field, value): def set_ccx_override(self, field, value):
"""Create a field override for the test CCX on <field> with <value>""" """Create a field override for the test CCX on <field> with <value>"""
...@@ -209,3 +210,28 @@ class TestCCX(ModuleStoreTestCase): ...@@ -209,3 +210,28 @@ class TestCCX(ModuleStoreTestCase):
self.set_ccx_override('max_student_enrollments_allowed', expected) self.set_ccx_override('max_student_enrollments_allowed', expected)
actual = self.ccx.max_student_enrollments_allowed # pylint: disable=no-member actual = self.ccx.max_student_enrollments_allowed # pylint: disable=no-member
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
def test_structure_json_default_empty(self):
"""
By default structure_json does not contain anything
"""
self.assertEqual(self.ccx.structure_json, None) # pylint: disable=no-member
self.assertEqual(self.ccx.structure, None) # pylint: disable=no-member
def test_structure_json(self):
"""
Test a json stored in the structure_json
"""
dummy_struct = [
"block-v1:Organization+CN101+CR-FALL15+type@chapter+block@Unit_4",
"block-v1:Organization+CN101+CR-FALL15+type@chapter+block@Unit_5",
"block-v1:Organization+CN101+CR-FALL15+type@chapter+block@Unit_11"
]
json_struct = json.dumps(dummy_struct)
ccx = CcxFactory(
course_id=self.course.id,
coach=self.coach,
structure_json=json_struct
)
self.assertEqual(ccx.structure_json, json_struct) # pylint: disable=no-member
self.assertEqual(ccx.structure, dummy_struct) # pylint: disable=no-member
""" """
test utils test utils
""" """
import mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from lms.djangoapps.ccx.tests.factories import CcxFactory
from student.roles import CourseCcxCoachRole from student.roles import CourseCcxCoachRole
from student.tests.factories import ( from student.tests.factories import (
AdminFactory, AdminFactory,
...@@ -12,7 +12,11 @@ from xmodule.modulestore.tests.django_utils import ( ...@@ -12,7 +12,11 @@ from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, ModuleStoreTestCase,
TEST_DATA_SPLIT_MODULESTORE) TEST_DATA_SPLIT_MODULESTORE)
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from opaque_keys.edx.keys import CourseKey
from lms.djangoapps.ccx import utils
from lms.djangoapps.ccx.tests.factories import CcxFactory
from lms.djangoapps.ccx.tests.utils import CcxTestCase
from ccx_keys.locator import CCXLocator from ccx_keys.locator import CCXLocator
...@@ -47,3 +51,45 @@ class TestGetCCXFromCCXLocator(ModuleStoreTestCase): ...@@ -47,3 +51,45 @@ class TestGetCCXFromCCXLocator(ModuleStoreTestCase):
course_key = CCXLocator.from_course_locator(self.course.id, ccx.id) course_key = CCXLocator.from_course_locator(self.course.id, ccx.id)
result = self.call_fut(course_key) result = self.call_fut(course_key)
self.assertEqual(result, ccx) self.assertEqual(result, ccx)
@attr('shard_1')
class TestGetCourseChapters(CcxTestCase):
"""
Tests for the `get_course_chapters` util function
"""
def setUp(self):
"""
Set up tests
"""
super(TestGetCourseChapters, self).setUp()
self.course_key = self.course.location.course_key
def test_get_structure_non_existing_key(self):
"""
Test to get the course structure
"""
self.assertEqual(utils.get_course_chapters(None), None)
# build a fake key
fake_course_key = CourseKey.from_string('course-v1:FakeOrg+CN1+CR-FALLNEVER1')
self.assertEqual(utils.get_course_chapters(fake_course_key), None)
@mock.patch('openedx.core.djangoapps.content.course_structures.models.CourseStructure.structure',
new_callable=mock.PropertyMock)
def test_wrong_course_structure(self, mocked_attr):
"""
Test the case where the course has an unexpected structure.
"""
mocked_attr.return_value = {'foo': 'bar'}
self.assertEqual(utils.get_course_chapters(self.course_key), [])
def test_get_chapters(self):
"""
Happy path
"""
course_chapters = utils.get_course_chapters(self.course_key)
self.assertEqual(len(course_chapters), 2)
self.assertEqual(
sorted(course_chapters),
sorted([unicode(child) for child in self.course.children])
)
...@@ -23,6 +23,7 @@ from instructor.enrollment import ( ...@@ -23,6 +23,7 @@ from instructor.enrollment import (
from instructor.access import allow_access from instructor.access import allow_access
from instructor.views.tools import get_student_from_identifier from instructor.views.tools import get_student_from_identifier
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.roles import CourseCcxCoachRole from student.roles import CourseCcxCoachRole
...@@ -284,3 +285,29 @@ def is_email(identifier): ...@@ -284,3 +285,29 @@ def is_email(identifier):
except ValidationError: except ValidationError:
return False return False
return True return True
def get_course_chapters(course_key):
"""
Extracts the chapters from a course structure.
If the course does not exist returns None.
If the structure does not contain 1st level children,
it returns an empty list.
Args:
course_key (CourseLocator): the course key
Returns:
list (string): a list of string representing the chapters modules
of the course
"""
if course_key is None:
return
try:
course_obj = CourseStructure.objects.get(course_id=course_key)
except CourseStructure.DoesNotExist:
return
course_struct = course_obj.structure
try:
return course_struct['blocks'][course_struct['root']].get('children', [])
except KeyError:
return []
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment