Merge pull request #4574 from edx/reruns/cms-server-side
Reruns/cms server side LMS-11016
This commit is contained in:
@@ -29,7 +29,7 @@ from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation, CourseLocator
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
@@ -46,6 +46,7 @@ from student.models import CourseEnrollment
|
||||
from student.roles import CourseCreatorRole, CourseInstructorRole
|
||||
from opaque_keys import InvalidKeyError
|
||||
from contentstore.tests.utils import get_url
|
||||
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
|
||||
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
@@ -1561,6 +1562,107 @@ class MetadataSaveTestCase(ContentStoreTestCase):
|
||||
pass
|
||||
|
||||
|
||||
class RerunCourseTest(ContentStoreTestCase):
|
||||
"""
|
||||
Tests for Rerunning a course via the view handler
|
||||
"""
|
||||
def setUp(self):
|
||||
super(RerunCourseTest, self).setUp()
|
||||
self.destination_course_data = {
|
||||
'org': 'MITx',
|
||||
'number': '111',
|
||||
'display_name': 'Robot Super Course',
|
||||
'run': '2013_Spring'
|
||||
}
|
||||
self.destination_course_key = _get_course_id(self.destination_course_data)
|
||||
|
||||
def post_rerun_request(self, source_course_key, response_code=200):
|
||||
"""Create and send an ajax post for the rerun request"""
|
||||
|
||||
# create data to post
|
||||
rerun_course_data = {'source_course_key': unicode(source_course_key)}
|
||||
rerun_course_data.update(self.destination_course_data)
|
||||
|
||||
# post the request
|
||||
course_url = get_url('course_handler', self.destination_course_key, 'course_key_string')
|
||||
response = self.client.ajax_post(course_url, rerun_course_data)
|
||||
|
||||
# verify response
|
||||
self.assertEqual(response.status_code, response_code)
|
||||
if response_code == 200:
|
||||
self.assertNotIn('ErrMsg', parse_json(response))
|
||||
|
||||
def create_course_listing_html(self, course_key):
|
||||
"""Creates html fragment that is created for the given course_key in the course listing section"""
|
||||
return '<a class="course-link" href="/course/{}"'.format(course_key)
|
||||
|
||||
def create_unsucceeded_course_action_html(self, course_key):
|
||||
"""Creates html fragment that is created for the given course_key in the unsucceeded course action section"""
|
||||
# TODO LMS-11011 Update this once the Rerun UI is implemented.
|
||||
return '<div class="unsucceeded-course-action" href="/course/{}"'.format(course_key)
|
||||
|
||||
def assertInCourseListing(self, course_key):
|
||||
"""
|
||||
Asserts that the given course key is in the accessible course listing section of the html
|
||||
and NOT in the unsucceeded course action section of the html.
|
||||
"""
|
||||
course_listing_html = self.client.get_html('/course/')
|
||||
self.assertIn(self.create_course_listing_html(course_key), course_listing_html.content)
|
||||
self.assertNotIn(self.create_unsucceeded_course_action_html(course_key), course_listing_html.content)
|
||||
|
||||
def assertInUnsucceededCourseActions(self, course_key):
|
||||
"""
|
||||
Asserts that the given course key is in the unsucceeded course action section of the html
|
||||
and NOT in the accessible course listing section of the html.
|
||||
"""
|
||||
course_listing_html = self.client.get_html('/course/')
|
||||
self.assertNotIn(self.create_course_listing_html(course_key), course_listing_html.content)
|
||||
# TODO Uncomment this once LMS-11011 is implemented.
|
||||
# self.assertIn(self.create_unsucceeded_course_action_html(course_key), course_listing_html.content)
|
||||
|
||||
def test_rerun_course_success(self):
|
||||
source_course = CourseFactory.create()
|
||||
self.post_rerun_request(source_course.id)
|
||||
|
||||
# Verify that the course rerun action is marked succeeded
|
||||
rerun_state = CourseRerunState.objects.find_first(course_key=self.destination_course_key)
|
||||
self.assertEquals(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED)
|
||||
|
||||
# Verify that the creator is now enrolled in the course.
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.destination_course_key))
|
||||
|
||||
# Verify both courses are in the course listing section
|
||||
self.assertInCourseListing(source_course.id)
|
||||
self.assertInCourseListing(self.destination_course_key)
|
||||
|
||||
def test_rerun_course_fail(self):
|
||||
existent_course_key = CourseFactory.create().id
|
||||
non_existent_course_key = CourseLocator("org", "non_existent_course", "run")
|
||||
self.post_rerun_request(non_existent_course_key)
|
||||
|
||||
# Verify that the course rerun action is marked failed
|
||||
rerun_state = CourseRerunState.objects.find_first(course_key=self.destination_course_key)
|
||||
self.assertEquals(rerun_state.state, CourseRerunUIStateManager.State.FAILED)
|
||||
self.assertIn("Cannot find a course at", rerun_state.message)
|
||||
|
||||
# Verify that the creator is not enrolled in the course.
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, non_existent_course_key))
|
||||
|
||||
# Verify that the existing course continues to be in the course listings
|
||||
self.assertInCourseListing(existent_course_key)
|
||||
|
||||
# Verify that the failed course is NOT in the course listings
|
||||
self.assertInUnsucceededCourseActions(non_existent_course_key)
|
||||
|
||||
def test_rerun_with_permission_denied(self):
|
||||
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
source_course = CourseFactory.create()
|
||||
auth.add_users(self.user, CourseCreatorRole(), self.user)
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.post_rerun_request(source_course.id, 403)
|
||||
|
||||
|
||||
class EntryPageTestCase(TestCase):
|
||||
"""
|
||||
Tests entry pages that aren't specific to a course.
|
||||
|
||||
@@ -18,9 +18,10 @@ from student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff, Or
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey, CourseLocator
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from course_action_state.models import CourseRerunState
|
||||
|
||||
TOTAL_COURSES_COUNT = 500
|
||||
USER_COURSES_COUNT = 50
|
||||
@@ -76,11 +77,11 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
self._create_course_with_access_groups(course_location, self.user)
|
||||
|
||||
# get courses through iterating all courses
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), 1)
|
||||
|
||||
# get courses by reversing group name formats
|
||||
courses_list_by_groups = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list_by_groups), 1)
|
||||
# check both course lists have same courses
|
||||
self.assertEqual(courses_list, courses_list_by_groups)
|
||||
@@ -98,11 +99,11 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor)
|
||||
|
||||
# get courses through iterating all courses
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(courses_list, [])
|
||||
|
||||
# get courses by reversing group name formats
|
||||
courses_list_by_groups = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(courses_list_by_groups, [])
|
||||
|
||||
def test_errored_course_regular_access(self):
|
||||
@@ -119,11 +120,11 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor)
|
||||
|
||||
# get courses through iterating all courses
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(courses_list, [])
|
||||
|
||||
# get courses by reversing group name formats
|
||||
courses_list_by_groups = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(courses_list_by_groups, [])
|
||||
self.assertEqual(courses_list, courses_list_by_groups)
|
||||
|
||||
@@ -135,11 +136,11 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
self._create_course_with_access_groups(course_key, self.user)
|
||||
|
||||
# get courses through iterating all courses
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), 1)
|
||||
|
||||
# get courses by reversing group name formats
|
||||
courses_list_by_groups = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list_by_groups), 1)
|
||||
# check both course lists have same courses
|
||||
self.assertEqual(courses_list, courses_list_by_groups)
|
||||
@@ -150,7 +151,7 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
CourseInstructorRole(course_key).add_users(self.user)
|
||||
|
||||
# test that get courses through iterating all courses now returns no course
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), 0)
|
||||
|
||||
def test_course_listing_performance(self):
|
||||
@@ -175,22 +176,22 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
|
||||
# time the get courses by iterating through all courses
|
||||
with Timer() as iteration_over_courses_time_1:
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), USER_COURSES_COUNT)
|
||||
|
||||
# time again the get courses by iterating through all courses
|
||||
with Timer() as iteration_over_courses_time_2:
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), USER_COURSES_COUNT)
|
||||
|
||||
# time the get courses by reversing django groups
|
||||
with Timer() as iteration_over_groups_time_1:
|
||||
courses_list = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list), USER_COURSES_COUNT)
|
||||
|
||||
# time again the get courses by reversing django groups
|
||||
with Timer() as iteration_over_groups_time_2:
|
||||
courses_list = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list), USER_COURSES_COUNT)
|
||||
|
||||
# test that the time taken by getting courses through reversing django groups is lower then the time
|
||||
@@ -201,10 +202,10 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
# Now count the db queries
|
||||
store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo)
|
||||
with check_mongo_calls(store, USER_COURSES_COUNT):
|
||||
courses_list = _accessible_courses_list_from_groups(self.request)
|
||||
_accessible_courses_list_from_groups(self.request)
|
||||
|
||||
with check_mongo_calls(store, 1):
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
_accessible_courses_list(self.request)
|
||||
|
||||
def test_get_course_list_with_same_course_id(self):
|
||||
"""
|
||||
@@ -215,11 +216,11 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
self._create_course_with_access_groups(course_location_caps, self.user)
|
||||
|
||||
# get courses through iterating all courses
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), 1)
|
||||
|
||||
# get courses by reversing group name formats
|
||||
courses_list_by_groups = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list_by_groups), 1)
|
||||
# check both course lists have same courses
|
||||
self.assertEqual(courses_list, courses_list_by_groups)
|
||||
@@ -229,22 +230,22 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
self._create_course_with_access_groups(course_location_camel, self.user)
|
||||
|
||||
# test that get courses through iterating all courses returns both courses
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), 2)
|
||||
|
||||
# test that get courses by reversing group name formats returns both courses
|
||||
courses_list_by_groups = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list_by_groups), 2)
|
||||
|
||||
# now delete first course (course_location_caps) and check that it is no longer accessible
|
||||
delete_course_and_groups(course_location_caps, self.user.id)
|
||||
|
||||
# test that get courses through iterating all courses now returns one course
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), 1)
|
||||
|
||||
# test that get courses by reversing group name formats also returns one course
|
||||
courses_list_by_groups = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list_by_groups), 1)
|
||||
|
||||
# now check that deleted course is not accessible
|
||||
@@ -282,7 +283,7 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
}},
|
||||
)
|
||||
|
||||
courses_list = _accessible_courses_list_from_groups(self.request)
|
||||
courses_list, __ = _accessible_courses_list_from_groups(self.request)
|
||||
self.assertEqual(len(courses_list), 1, courses_list)
|
||||
|
||||
@ddt.data(OrgStaffRole('AwesomeOrg'), OrgInstructorRole('AwesomeOrg'))
|
||||
@@ -310,5 +311,34 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
|
||||
with self.assertRaises(AccessListFallback):
|
||||
_accessible_courses_list_from_groups(self.request)
|
||||
courses_list = _accessible_courses_list(self.request)
|
||||
courses_list, __ = _accessible_courses_list(self.request)
|
||||
self.assertEqual(len(courses_list), 2)
|
||||
|
||||
def test_course_listing_with_actions_in_progress(self):
|
||||
sourse_course_key = CourseLocator('source-Org', 'source-Course', 'source-Run')
|
||||
|
||||
num_courses_to_create = 3
|
||||
courses = [
|
||||
self._create_course_with_access_groups(CourseLocator('Org', 'CreatedCourse' + str(num), 'Run'), self.user)
|
||||
for num in range(0, num_courses_to_create)
|
||||
]
|
||||
courses_in_progress = [
|
||||
self._create_course_with_access_groups(CourseLocator('Org', 'InProgressCourse' + str(num), 'Run'), self.user)
|
||||
for num in range(0, num_courses_to_create)
|
||||
]
|
||||
|
||||
# simulate initiation of course actions
|
||||
for course in courses_in_progress:
|
||||
CourseRerunState.objects.initiated(sourse_course_key, destination_course_key=course.id, user=self.user)
|
||||
|
||||
# verify return values
|
||||
for method in (_accessible_courses_list_from_groups, _accessible_courses_list):
|
||||
def set_of_course_keys(course_list, key_attribute_name='id'):
|
||||
"""Returns a python set of course keys by accessing the key with the given attribute name."""
|
||||
return set(getattr(c, key_attribute_name) for c in course_list)
|
||||
|
||||
found_courses, unsucceeded_course_actions = method(self.request)
|
||||
self.assertSetEqual(set_of_course_keys(courses + courses_in_progress), set_of_course_keys(found_courses))
|
||||
self.assertSetEqual(
|
||||
set_of_course_keys(courses_in_progress), set_of_course_keys(unsucceeded_course_actions, 'course_key')
|
||||
)
|
||||
|
||||
@@ -95,6 +95,7 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
client = Client()
|
||||
if authenticate:
|
||||
client.login(username=nonstaff.username, password=password)
|
||||
nonstaff.is_authenticated = True
|
||||
return client, nonstaff
|
||||
|
||||
def populate_course(self):
|
||||
|
||||
@@ -9,6 +9,8 @@ from pytz import UTC
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.urlresolvers import reverse
|
||||
from django_comment_common.models import assign_default_role
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
@@ -16,6 +18,8 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from opaque_keys.edx.keys import UsageKey, CourseKey
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from student.models import CourseEnrollment
|
||||
from student import auth
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -26,25 +30,58 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
|
||||
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
|
||||
|
||||
|
||||
def delete_course_and_groups(course_id, user_id):
|
||||
def add_instructor(course_key, requesting_user, new_instructor):
|
||||
"""
|
||||
This deletes the courseware associated with a course_id as well as cleaning update_item
|
||||
Adds given user as instructor and staff to the given course,
|
||||
after verifying that the requesting_user has permission to do so.
|
||||
"""
|
||||
# can't use auth.add_users here b/c it requires user to already have Instructor perms in this course
|
||||
CourseInstructorRole(course_key).add_users(new_instructor)
|
||||
auth.add_users(requesting_user, CourseStaffRole(course_key), new_instructor)
|
||||
|
||||
|
||||
def initialize_permissions(course_key, user_who_created_course):
|
||||
"""
|
||||
Initializes a new course by enrolling the course creator as a student,
|
||||
and initializing Forum by seeding its permissions and assigning default roles.
|
||||
"""
|
||||
# seed the forums
|
||||
seed_permissions_roles(course_key)
|
||||
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
CourseEnrollment.enroll(user_who_created_course, course_key)
|
||||
|
||||
# set default forum roles (assign 'Student' role)
|
||||
assign_default_role(course_key, user_who_created_course)
|
||||
|
||||
|
||||
def remove_all_instructors(course_key):
|
||||
"""
|
||||
Removes given user as instructor and staff to the given course,
|
||||
after verifying that the requesting_user has permission to do so.
|
||||
"""
|
||||
staff_role = CourseStaffRole(course_key)
|
||||
staff_role.remove_users(*staff_role.users_with_role())
|
||||
instructor_role = CourseInstructorRole(course_key)
|
||||
instructor_role.remove_users(*instructor_role.users_with_role())
|
||||
|
||||
|
||||
def delete_course_and_groups(course_key, user_id):
|
||||
"""
|
||||
This deletes the courseware associated with a course_key as well as cleaning update_item
|
||||
the various user table stuff (groups, permissions, etc.)
|
||||
"""
|
||||
module_store = modulestore()
|
||||
|
||||
with module_store.bulk_write_operations(course_id):
|
||||
module_store.delete_course(course_id, user_id)
|
||||
with module_store.bulk_write_operations(course_key):
|
||||
module_store.delete_course(course_key, user_id)
|
||||
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
try:
|
||||
staff_role = CourseStaffRole(course_id)
|
||||
staff_role.remove_users(*staff_role.users_with_role())
|
||||
instructor_role = CourseInstructorRole(course_id)
|
||||
instructor_role.remove_users(*instructor_role.users_with_role())
|
||||
remove_all_instructors(course_key)
|
||||
except Exception as err:
|
||||
log.error("Error in deleting course groups for {0}: {1}".format(course_id, err))
|
||||
log.error("Error in deleting course groups for {0}: {1}".format(course_key, err))
|
||||
|
||||
|
||||
def get_lms_link_for_item(location, preview=False):
|
||||
@@ -64,19 +101,19 @@ def get_lms_link_for_item(location, preview=False):
|
||||
else:
|
||||
lms_base = settings.LMS_BASE
|
||||
|
||||
return u"//{lms_base}/courses/{course_id}/jump_to/{location}".format(
|
||||
return u"//{lms_base}/courses/{course_key}/jump_to/{location}".format(
|
||||
lms_base=lms_base,
|
||||
course_id=location.course_key.to_deprecated_string(),
|
||||
course_key=location.course_key.to_deprecated_string(),
|
||||
location=location.to_deprecated_string(),
|
||||
)
|
||||
|
||||
|
||||
def get_lms_link_for_about_page(course_id):
|
||||
def get_lms_link_for_about_page(course_key):
|
||||
"""
|
||||
Returns the url to the course about page from the location tuple.
|
||||
"""
|
||||
|
||||
assert(isinstance(course_id, CourseKey))
|
||||
assert(isinstance(course_key, CourseKey))
|
||||
|
||||
if settings.FEATURES.get('ENABLE_MKTG_SITE', False):
|
||||
if not hasattr(settings, 'MKTG_URLS'):
|
||||
@@ -101,9 +138,9 @@ def get_lms_link_for_about_page(course_id):
|
||||
else:
|
||||
return None
|
||||
|
||||
return u"//{about_base_url}/courses/{course_id}/about".format(
|
||||
return u"//{about_base_url}/courses/{course_key}/about".format(
|
||||
about_base_url=about_base,
|
||||
course_id=course_id.to_deprecated_string()
|
||||
course_key=course_key.to_deprecated_string()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,14 +30,16 @@ from opaque_keys.edx.locations import Location, SlashSeparatedCourseKey
|
||||
|
||||
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
|
||||
from contentstore.utils import (
|
||||
add_instructor,
|
||||
initialize_permissions,
|
||||
get_lms_link_for_item,
|
||||
add_extra_panel_tab,
|
||||
remove_extra_panel_tab,
|
||||
reverse_course_url,
|
||||
reverse_usage_url,
|
||||
reverse_url,
|
||||
)
|
||||
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from util.json_request import expect_json
|
||||
@@ -50,21 +52,20 @@ from .component import (
|
||||
ADVANCED_COMPONENT_POLICY_KEY,
|
||||
SPLIT_TEST_COMPONENT_TYPE,
|
||||
)
|
||||
|
||||
from django_comment_common.models import assign_default_role
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
from student.roles import CourseRole, UserBasedRole
|
||||
from .tasks import rerun_course
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
|
||||
from contentstore import utils
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff
|
||||
from student.roles import (
|
||||
CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff, UserBasedRole
|
||||
)
|
||||
from student import auth
|
||||
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
|
||||
|
||||
from microsite_configuration import microsite
|
||||
|
||||
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler',
|
||||
'settings_handler',
|
||||
'grading_handler',
|
||||
@@ -123,7 +124,7 @@ def course_handler(request, course_key_string=None):
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(_course_json(request, CourseKey.from_string(course_key_string)))
|
||||
elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access
|
||||
return create_new_course(request)
|
||||
return _create_or_rerun_course(request)
|
||||
elif not has_course_access(request.user, CourseKey.from_string(course_key_string)):
|
||||
raise PermissionDenied()
|
||||
elif request.method == 'PUT':
|
||||
@@ -171,26 +172,29 @@ def _accessible_courses_list(request):
|
||||
"""
|
||||
List all courses available to the logged in user by iterating through all the courses
|
||||
"""
|
||||
courses = modulestore().get_courses()
|
||||
|
||||
# filter out courses that we don't have access to
|
||||
def course_filter(course):
|
||||
"""
|
||||
Get courses to which this user has access
|
||||
Filter out unusable and inaccessible courses
|
||||
"""
|
||||
if isinstance(course, ErrorDescriptor):
|
||||
return False
|
||||
|
||||
if GlobalStaff().has_user(request.user):
|
||||
return course.location.course != 'templates'
|
||||
# pylint: disable=fixme
|
||||
# TODO remove this condition when templates purged from db
|
||||
if course.location.course == 'templates':
|
||||
return False
|
||||
|
||||
return (has_course_access(request.user, course.id)
|
||||
# pylint: disable=fixme
|
||||
# TODO remove this condition when templates purged from db
|
||||
and course.location.course != 'templates'
|
||||
)
|
||||
courses = filter(course_filter, courses)
|
||||
return courses
|
||||
return has_course_access(request.user, course.id)
|
||||
|
||||
courses = filter(course_filter, modulestore().get_courses())
|
||||
unsucceeded_course_actions = [
|
||||
course for course in
|
||||
CourseRerunState.objects.find_all(
|
||||
exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, should_display=True
|
||||
)
|
||||
if has_course_access(request.user, course.course_key)
|
||||
]
|
||||
return courses, unsucceeded_course_actions
|
||||
|
||||
|
||||
def _accessible_courses_list_from_groups(request):
|
||||
@@ -198,6 +202,7 @@ def _accessible_courses_list_from_groups(request):
|
||||
List all courses available to the logged in user by reversing access group names
|
||||
"""
|
||||
courses_list = {}
|
||||
unsucceeded_course_actions = []
|
||||
|
||||
instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role()
|
||||
staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role()
|
||||
@@ -209,6 +214,15 @@ def _accessible_courses_list_from_groups(request):
|
||||
# If the course_access does not have a course_id, it's an org-based role, so we fall back
|
||||
raise AccessListFallback
|
||||
if course_key not in courses_list:
|
||||
# check for any course action state for this course
|
||||
unsucceeded_course_actions.extend(
|
||||
CourseRerunState.objects.find_all(
|
||||
exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED},
|
||||
should_display=True,
|
||||
course_key=course_key,
|
||||
)
|
||||
)
|
||||
# check for the course itself
|
||||
try:
|
||||
course = modulestore().get_course(course_key)
|
||||
except ItemNotFoundError:
|
||||
@@ -218,7 +232,7 @@ def _accessible_courses_list_from_groups(request):
|
||||
# ignore deleted or errored courses
|
||||
courses_list[course_key] = course
|
||||
|
||||
return courses_list.values()
|
||||
return courses_list.values(), unsucceeded_course_actions
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -231,14 +245,14 @@ def course_listing(request):
|
||||
"""
|
||||
if GlobalStaff().has_user(request.user):
|
||||
# user has global access so no need to get courses from django groups
|
||||
courses = _accessible_courses_list(request)
|
||||
courses, unsucceeded_course_actions = _accessible_courses_list(request)
|
||||
else:
|
||||
try:
|
||||
courses = _accessible_courses_list_from_groups(request)
|
||||
courses, unsucceeded_course_actions = _accessible_courses_list_from_groups(request)
|
||||
except AccessListFallback:
|
||||
# user have some old groups or there was some error getting courses from django groups
|
||||
# so fallback to iterating through all courses
|
||||
courses = _accessible_courses_list(request)
|
||||
courses, unsucceeded_course_actions = _accessible_courses_list(request)
|
||||
|
||||
def format_course_for_view(course):
|
||||
"""
|
||||
@@ -253,8 +267,17 @@ def course_listing(request):
|
||||
course.location.name
|
||||
)
|
||||
|
||||
# remove any courses in courses that are also in the unsucceeded_course_actions list
|
||||
unsucceeded_action_course_keys = [uca.course_key for uca in unsucceeded_course_actions]
|
||||
courses = [
|
||||
format_course_for_view(c)
|
||||
for c in courses
|
||||
if not isinstance(c, ErrorDescriptor) and (c.id not in unsucceeded_action_course_keys)
|
||||
]
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)],
|
||||
'courses': courses,
|
||||
'unsucceeded_course_actions': unsucceeded_course_actions,
|
||||
'user': request.user,
|
||||
'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
@@ -289,80 +312,36 @@ def course_index(request, course_key):
|
||||
|
||||
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
def _create_or_rerun_course(request):
|
||||
"""
|
||||
Create a new course.
|
||||
|
||||
Returns the URL for the course overview page.
|
||||
To be called by requests that create a new destination course (i.e., create_new_course and rerun_course)
|
||||
Returns the destination course_key and overriding fields for the new course.
|
||||
Raises InvalidLocationError and InvalidKeyError
|
||||
"""
|
||||
if not auth.has_access(request.user, CourseCreatorRole()):
|
||||
raise PermissionDenied()
|
||||
|
||||
org = request.json.get('org')
|
||||
number = request.json.get('number')
|
||||
display_name = request.json.get('display_name')
|
||||
run = request.json.get('run')
|
||||
|
||||
# allow/disable unicode characters in course_id according to settings
|
||||
if not settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID'):
|
||||
if _has_non_ascii_characters(org) or _has_non_ascii_characters(number) or _has_non_ascii_characters(run):
|
||||
return JsonResponse(
|
||||
{'error': _('Special characters not allowed in organization, course number, and course run.')},
|
||||
status=400
|
||||
)
|
||||
|
||||
try:
|
||||
org = request.json.get('org')
|
||||
number = request.json.get('number')
|
||||
display_name = request.json.get('display_name')
|
||||
run = request.json.get('run')
|
||||
|
||||
# allow/disable unicode characters in course_id according to settings
|
||||
if not settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID'):
|
||||
if _has_non_ascii_characters(org) or _has_non_ascii_characters(number) or _has_non_ascii_characters(run):
|
||||
return JsonResponse(
|
||||
{'error': _('Special characters not allowed in organization, course number, and course run.')},
|
||||
status=400
|
||||
)
|
||||
|
||||
course_key = SlashSeparatedCourseKey(org, number, run)
|
||||
fields = {'display_name': display_name} if display_name is not None else {}
|
||||
|
||||
# instantiate the CourseDescriptor and then persist it
|
||||
# note: no system to pass
|
||||
if display_name is None:
|
||||
metadata = {}
|
||||
if 'source_course_key' in request.json:
|
||||
return _rerun_course(request, course_key, fields)
|
||||
else:
|
||||
metadata = {'display_name': display_name}
|
||||
|
||||
# Set a unique wiki_slug for newly created courses. To maintain active wiki_slugs for
|
||||
# existing xml courses this cannot be changed in CourseDescriptor.
|
||||
# # TODO get rid of defining wiki slug in this org/course/run specific way and reconcile
|
||||
# w/ xmodule.course_module.CourseDescriptor.__init__
|
||||
wiki_slug = u"{0}.{1}.{2}".format(course_key.org, course_key.course, course_key.run)
|
||||
definition_data = {'wiki_slug': wiki_slug}
|
||||
|
||||
# Create the course then fetch it from the modulestore
|
||||
# Check if role permissions group for a course named like this already exists
|
||||
# Important because role groups are case insensitive
|
||||
if CourseRole.course_group_already_exists(course_key):
|
||||
raise InvalidLocationError()
|
||||
|
||||
fields = {}
|
||||
fields.update(definition_data)
|
||||
fields.update(metadata)
|
||||
|
||||
# Creating the course raises InvalidLocationError if an existing course with this org/name is found
|
||||
new_course = modulestore().create_course(
|
||||
course_key.org,
|
||||
course_key.course,
|
||||
course_key.run,
|
||||
request.user.id,
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
# can't use auth.add_users here b/c it requires request.user to already have Instructor perms in this course
|
||||
# however, we can assume that b/c this user had authority to create the course, the user can add themselves
|
||||
CourseInstructorRole(new_course.id).add_users(request.user)
|
||||
auth.add_users(request.user, CourseStaffRole(new_course.id), request.user)
|
||||
|
||||
# seed the forums
|
||||
seed_permissions_roles(new_course.id)
|
||||
|
||||
# auto-enroll the course creator in the course so that "View Live" will
|
||||
# work.
|
||||
CourseEnrollment.enroll(request.user, new_course.id)
|
||||
_users_assign_default_role(new_course.id)
|
||||
|
||||
return JsonResponse({
|
||||
'url': reverse_course_url('course_handler', new_course.id)
|
||||
})
|
||||
return _create_new_course(request, course_key, fields)
|
||||
|
||||
except InvalidLocationError:
|
||||
return JsonResponse({
|
||||
@@ -384,13 +363,62 @@ def create_new_course(request):
|
||||
)
|
||||
|
||||
|
||||
def _users_assign_default_role(course_id):
|
||||
def _create_new_course(request, course_key, fields):
|
||||
"""
|
||||
Assign 'Student' role to all previous users (if any) for this course
|
||||
Create a new course.
|
||||
Returns the URL for the course overview page.
|
||||
"""
|
||||
enrollments = CourseEnrollment.objects.filter(course_id=course_id)
|
||||
for enrollment in enrollments:
|
||||
assign_default_role(course_id, enrollment.user)
|
||||
# Set a unique wiki_slug for newly created courses. To maintain active wiki_slugs for
|
||||
# existing xml courses this cannot be changed in CourseDescriptor.
|
||||
# # TODO get rid of defining wiki slug in this org/course/run specific way and reconcile
|
||||
# w/ xmodule.course_module.CourseDescriptor.__init__
|
||||
wiki_slug = u"{0}.{1}.{2}".format(course_key.org, course_key.course, course_key.run)
|
||||
definition_data = {'wiki_slug': wiki_slug}
|
||||
fields.update(definition_data)
|
||||
|
||||
# Creating the course raises InvalidLocationError if an existing course with this org/name is found
|
||||
new_course = modulestore().create_course(
|
||||
course_key.org,
|
||||
course_key.course,
|
||||
course_key.run,
|
||||
request.user.id,
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
# Make sure user has instructor and staff access to the new course
|
||||
add_instructor(new_course.id, request.user, request.user)
|
||||
|
||||
# Initialize permissions for user in the new course
|
||||
initialize_permissions(new_course.id, request.user)
|
||||
|
||||
return JsonResponse({
|
||||
'url': reverse_course_url('course_handler', new_course.id)
|
||||
})
|
||||
|
||||
|
||||
def _rerun_course(request, destination_course_key, fields):
|
||||
"""
|
||||
Reruns an existing course.
|
||||
Returns the URL for the course listing page.
|
||||
"""
|
||||
source_course_key = CourseKey.from_string(request.json.get('source_course_key'))
|
||||
|
||||
# verify user has access to the original course
|
||||
if not has_course_access(request.user, source_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, request.user, request.user)
|
||||
|
||||
# Mark the action as initiated
|
||||
CourseRerunState.objects.initiated(source_course_key, destination_course_key, request.user)
|
||||
|
||||
# Rerun the course as a new celery task
|
||||
rerun_course.delay(source_course_key, destination_course_key, request.user.id, fields)
|
||||
|
||||
# Return course listing page
|
||||
return JsonResponse({'url': reverse_url('course_handler')})
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
32
cms/djangoapps/contentstore/views/tasks.py
Normal file
32
cms/djangoapps/contentstore/views/tasks.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
This file contains celery tasks for contentstore views
|
||||
"""
|
||||
|
||||
from celery.task import task
|
||||
from django.contrib.auth.models import User
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from course_action_state.models import CourseRerunState
|
||||
from contentstore.utils import initialize_permissions
|
||||
|
||||
|
||||
@task()
|
||||
def rerun_course(source_course_key, destination_course_key, user_id, fields=None):
|
||||
"""
|
||||
Reruns a course in a new celery task.
|
||||
"""
|
||||
try:
|
||||
modulestore().clone_course(source_course_key, destination_course_key, user_id, fields=fields)
|
||||
|
||||
# set initial permissions for the user to access the course.
|
||||
initialize_permissions(destination_course_key, User.objects.get(id=user_id))
|
||||
|
||||
# update state: Succeeded
|
||||
CourseRerunState.objects.succeeded(course_key=destination_course_key)
|
||||
|
||||
# catch all exceptions so we can update the state and properly cleanup the course.
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# update state: Failed
|
||||
CourseRerunState.objects.failed(course_key=destination_course_key, exception=exc)
|
||||
|
||||
# cleanup any remnants of the course
|
||||
modulestore().delete_course(destination_course_key, user_id)
|
||||
@@ -547,6 +547,9 @@ INSTALLED_APPS = (
|
||||
|
||||
# Monitoring signals
|
||||
'monitoring',
|
||||
|
||||
# Course action state
|
||||
'course_action_state'
|
||||
)
|
||||
|
||||
|
||||
|
||||
0
common/djangoapps/course_action_state/__init__.py
Normal file
0
common/djangoapps/course_action_state/__init__.py
Normal file
150
common/djangoapps/course_action_state/managers.py
Normal file
150
common/djangoapps/course_action_state/managers.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Model Managers for Course Actions
|
||||
"""
|
||||
from django.db import models, transaction
|
||||
|
||||
|
||||
class CourseActionStateManager(models.Manager):
|
||||
"""
|
||||
An abstract Model Manager class for Course Action State models.
|
||||
This abstract class expects child classes to define the ACTION (string) field.
|
||||
"""
|
||||
class Meta:
|
||||
"""Abstract manager class, with subclasses defining the ACTION (string) field."""
|
||||
abstract = True
|
||||
|
||||
def find_all(self, exclude_args=None, **kwargs):
|
||||
"""
|
||||
Finds and returns all entries for this action and the given field names-and-values in kwargs.
|
||||
The exclude_args dict allows excluding entries with the field names-and-values in exclude_args.
|
||||
"""
|
||||
return self.filter(action=self.ACTION, **kwargs).exclude(**(exclude_args or {})) # pylint: disable=no-member
|
||||
|
||||
def find_first(self, exclude_args=None, **kwargs):
|
||||
"""
|
||||
Returns the first entry for the this action and the given fields in kwargs, if found.
|
||||
The exclude_args dict allows excluding entries with the field names-and-values in exclude_args.
|
||||
|
||||
Raises ItemNotFoundError if more than 1 entry is found.
|
||||
|
||||
There may or may not be greater than one entry, depending on the usage pattern for this Action.
|
||||
"""
|
||||
objects = self.find_all(exclude_args=exclude_args, **kwargs)
|
||||
if len(objects) == 0:
|
||||
raise CourseActionStateItemNotFoundError(
|
||||
"No entry found for action {action} with filter {filter}, excluding {exclude}".format(
|
||||
action=self.ACTION, # pylint: disable=no-member
|
||||
filter=kwargs,
|
||||
exclude=exclude_args,
|
||||
))
|
||||
else:
|
||||
return objects[0]
|
||||
|
||||
def delete(self, entry_id):
|
||||
"""
|
||||
Deletes the entry with given id.
|
||||
"""
|
||||
self.filter(id=entry_id).delete()
|
||||
|
||||
|
||||
class CourseActionUIStateManager(CourseActionStateManager):
|
||||
"""
|
||||
A Model Manager subclass of the CourseActionStateManager class that is aware of UI-related fields related
|
||||
to state management, including "should_display" and "message".
|
||||
"""
|
||||
|
||||
# add transaction protection to revert changes by get_or_create if an exception is raised before the final save.
|
||||
@transaction.commit_on_success
|
||||
def update_state(
|
||||
self, course_key, new_state, should_display=True, message="", user=None, allow_not_found=False, **kwargs
|
||||
):
|
||||
"""
|
||||
Updates the state of the given course for this Action with the given data.
|
||||
If allow_not_found is True, automatically creates an entry if it doesn't exist.
|
||||
Raises CourseActionStateException if allow_not_found is False and an entry for the given course
|
||||
for this Action doesn't exist.
|
||||
"""
|
||||
state_object, created = self.get_or_create(course_key=course_key, action=self.ACTION) # pylint: disable=no-member
|
||||
|
||||
if created:
|
||||
if allow_not_found:
|
||||
state_object.created_user = user
|
||||
else:
|
||||
raise CourseActionStateItemNotFoundError(
|
||||
"Cannot update non-existent entry for course_key {course_key} and action {action}".format(
|
||||
action=self.ACTION, # pylint: disable=no-member
|
||||
course_key=course_key,
|
||||
))
|
||||
|
||||
# some state changes may not be user-initiated so override the user field only when provided
|
||||
if user:
|
||||
state_object.updated_user = user
|
||||
|
||||
state_object.state = new_state
|
||||
state_object.should_display = should_display
|
||||
state_object.message = message
|
||||
|
||||
# update any additional fields in kwargs
|
||||
if kwargs:
|
||||
for key, value in kwargs.iteritems():
|
||||
setattr(state_object, key, value)
|
||||
|
||||
state_object.save()
|
||||
return state_object
|
||||
|
||||
def update_should_display(self, entry_id, user, should_display):
|
||||
"""
|
||||
Updates the should_display field with the given value for the entry for the given id.
|
||||
"""
|
||||
self.update(id=entry_id, updated_user=user, should_display=should_display)
|
||||
|
||||
|
||||
class CourseRerunUIStateManager(CourseActionUIStateManager):
|
||||
"""
|
||||
A concrete model Manager for the Reruns Action.
|
||||
"""
|
||||
ACTION = "rerun"
|
||||
|
||||
class State(object):
|
||||
"""
|
||||
An Enum class for maintaining the list of possible states for Reruns.
|
||||
"""
|
||||
IN_PROGRESS = "in_progress"
|
||||
FAILED = "failed"
|
||||
SUCCEEDED = "succeeded"
|
||||
|
||||
def initiated(self, source_course_key, destination_course_key, user):
|
||||
"""
|
||||
To be called when a new rerun is initiated for the given course by the given user.
|
||||
"""
|
||||
self.update_state(
|
||||
course_key=destination_course_key,
|
||||
new_state=self.State.IN_PROGRESS,
|
||||
user=user,
|
||||
allow_not_found=True,
|
||||
source_course_key=source_course_key,
|
||||
)
|
||||
|
||||
def succeeded(self, course_key):
|
||||
"""
|
||||
To be called when an existing rerun for the given course has successfully completed.
|
||||
"""
|
||||
self.update_state(
|
||||
course_key=course_key,
|
||||
new_state=self.State.SUCCEEDED,
|
||||
)
|
||||
|
||||
def failed(self, course_key, exception):
|
||||
"""
|
||||
To be called when an existing rerun for the given course has failed with the given exception.
|
||||
"""
|
||||
self.update_state(
|
||||
course_key=course_key,
|
||||
new_state=self.State.FAILED,
|
||||
message=exception.message,
|
||||
)
|
||||
|
||||
|
||||
class CourseActionStateItemNotFoundError(Exception):
|
||||
"""An exception class for errors specific to Course Action states."""
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'CourseRerunState'
|
||||
db.create_table('course_action_state_coursererunstate', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('created_time', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('updated_time', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
|
||||
('created_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='created_by_user+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])),
|
||||
('updated_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='updated_by_user+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])),
|
||||
('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
|
||||
('action', self.gf('django.db.models.fields.CharField')(max_length=100, db_index=True)),
|
||||
('state', self.gf('django.db.models.fields.CharField')(max_length=50)),
|
||||
('should_display', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
('message', self.gf('django.db.models.fields.CharField')(max_length=1000)),
|
||||
('source_course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
|
||||
))
|
||||
db.send_create_signal('course_action_state', ['CourseRerunState'])
|
||||
|
||||
# Adding unique constraint on 'CourseRerunState', fields ['course_key', 'action']
|
||||
db.create_unique('course_action_state_coursererunstate', ['course_key', 'action'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'CourseRerunState', fields ['course_key', 'action']
|
||||
db.delete_unique('course_action_state_coursererunstate', ['course_key', 'action'])
|
||||
|
||||
# Deleting model 'CourseRerunState'
|
||||
db.delete_table('course_action_state_coursererunstate')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'course_action_state.coursererunstate': {
|
||||
'Meta': {'unique_together': "(('course_key', 'action'),)", 'object_name': 'CourseRerunState'},
|
||||
'action': ('django.db.models.fields.CharField', [], {'max_length': '100', 'db_index': 'True'}),
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created_time': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'created_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'message': ('django.db.models.fields.CharField', [], {'max_length': '1000'}),
|
||||
'should_display': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'source_course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'state': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'updated_time': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
|
||||
'updated_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'updated_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['course_action_state']
|
||||
114
common/djangoapps/course_action_state/models.py
Normal file
114
common/djangoapps/course_action_state/models.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Models for course action state
|
||||
|
||||
If you make changes to this model, be sure to create an appropriate migration
|
||||
file and check it in at the same time as your model changes. To do that,
|
||||
|
||||
1. Go to the edx-platform dir
|
||||
2. ./manage.py cms schemamigration course_action_state --auto description_of_your_change
|
||||
3. It adds the migration file to edx-platform/common/djangoapps/course_action_state/migrations/
|
||||
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from xmodule_django.models import CourseKeyField
|
||||
from course_action_state.managers import CourseActionStateManager, CourseRerunUIStateManager
|
||||
|
||||
|
||||
class CourseActionState(models.Model):
|
||||
"""
|
||||
A django model for maintaining state data for course actions that take a long time.
|
||||
For example: course copying (reruns), import, export, and validation.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
For performance reasons, we disable "concrete inheritance", by making the Model base class abstract.
|
||||
With the "abstract base class" inheritance model, tables are only created for derived models, not for
|
||||
the parent classes. This way, we don't have extra overhead of extra tables and joins that would
|
||||
otherwise happen with the multi-table inheritance model.
|
||||
"""
|
||||
abstract = True
|
||||
|
||||
# FIELDS
|
||||
|
||||
# Created is the time this action was initiated
|
||||
created_time = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Updated is the last time this entry was modified
|
||||
updated_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
# User who initiated the course action
|
||||
created_user = models.ForeignKey(
|
||||
User,
|
||||
# allow NULL values in case the action is not initiated by a user (e.g., a background thread)
|
||||
null=True,
|
||||
# set on_delete to SET_NULL to prevent this model from being deleted in the event the user is deleted
|
||||
on_delete=models.SET_NULL,
|
||||
# add a '+' at the end to prevent a backward relation from the User model
|
||||
related_name='created_by_user+'
|
||||
)
|
||||
|
||||
# User who last updated the course action
|
||||
updated_user = models.ForeignKey(
|
||||
User,
|
||||
# allow NULL values in case the action is not updated by a user (e.g., a background thread)
|
||||
null=True,
|
||||
# set on_delete to SET_NULL to prevent this model from being deleted in the event the user is deleted
|
||||
on_delete=models.SET_NULL,
|
||||
# add a '+' at the end to prevent a backward relation from the User model
|
||||
related_name='updated_by_user+'
|
||||
)
|
||||
|
||||
# Course that is being acted upon
|
||||
course_key = CourseKeyField(max_length=255, db_index=True)
|
||||
|
||||
# Action that is being taken on the course
|
||||
action = models.CharField(max_length=100, db_index=True)
|
||||
|
||||
# Current state of the action.
|
||||
state = models.CharField(max_length=50)
|
||||
|
||||
# MANAGERS
|
||||
objects = CourseActionStateManager()
|
||||
|
||||
|
||||
class CourseActionUIState(CourseActionState):
|
||||
"""
|
||||
An abstract django model that is a sub-class of CourseActionState with additional fields related to UI.
|
||||
"""
|
||||
class Meta:
|
||||
"""
|
||||
See comment in CourseActionState on disabling "concrete inheritance".
|
||||
"""
|
||||
abstract = True
|
||||
|
||||
# FIELDS
|
||||
|
||||
# Whether or not the status should be displayed to users
|
||||
should_display = models.BooleanField()
|
||||
|
||||
# Message related to the status
|
||||
message = models.CharField(max_length=1000)
|
||||
|
||||
|
||||
# Rerun courses also need these fields. All rerun course actions will have a row here as well.
|
||||
class CourseRerunState(CourseActionUIState):
|
||||
"""
|
||||
A concrete django model for maintaining state specifically for the Action Course Reruns.
|
||||
"""
|
||||
class Meta:
|
||||
"""
|
||||
Set the (destination) course_key field to be unique for the rerun action
|
||||
Although multiple reruns can be in progress simultaneously for a particular source course_key,
|
||||
only a single rerun action can be in progress for the destination course_key.
|
||||
"""
|
||||
unique_together = ("course_key", "action")
|
||||
|
||||
# FIELDS
|
||||
# Original course that is being rerun
|
||||
source_course_key = CourseKeyField(max_length=255, db_index=True)
|
||||
|
||||
# MANAGERS
|
||||
# Override the abstract class' manager with a Rerun-specific manager that inherits from the base class' manager.
|
||||
objects = CourseRerunUIStateManager()
|
||||
159
common/djangoapps/course_action_state/tests/test_managers.py
Normal file
159
common/djangoapps/course_action_state/tests/test_managers.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# pylint: disable=invalid-name, attribute-defined-outside-init
|
||||
"""
|
||||
Tests for basic common operations related to Course Action State managers
|
||||
"""
|
||||
from ddt import ddt, data
|
||||
from django.test import TestCase
|
||||
from collections import namedtuple
|
||||
from opaque_keys.edx.locations import CourseLocator
|
||||
from course_action_state.models import CourseRerunState
|
||||
from course_action_state.managers import CourseActionStateItemNotFoundError
|
||||
|
||||
|
||||
# Sequence of Action models to be tested with ddt.
|
||||
COURSE_ACTION_STATES = (CourseRerunState, )
|
||||
|
||||
|
||||
class TestCourseActionStateManagerBase(TestCase):
|
||||
"""
|
||||
Base class for testing Course Action State Managers.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.course_key = CourseLocator("test_org", "test_course_num", "test_run")
|
||||
|
||||
|
||||
@ddt
|
||||
class TestCourseActionStateManager(TestCourseActionStateManagerBase):
|
||||
"""
|
||||
Test class for testing the CourseActionStateManager.
|
||||
"""
|
||||
@data(*COURSE_ACTION_STATES)
|
||||
def test_update_state_allow_not_found_is_false(self, action_class):
|
||||
with self.assertRaises(CourseActionStateItemNotFoundError):
|
||||
action_class.objects.update_state(self.course_key, "fake_state", allow_not_found=False)
|
||||
|
||||
@data(*COURSE_ACTION_STATES)
|
||||
def test_update_state_allow_not_found(self, action_class):
|
||||
action_class.objects.update_state(self.course_key, "initial_state", allow_not_found=True)
|
||||
self.assertIsNotNone(
|
||||
action_class.objects.find_first(course_key=self.course_key)
|
||||
)
|
||||
|
||||
@data(*COURSE_ACTION_STATES)
|
||||
def test_delete(self, action_class):
|
||||
obj = action_class.objects.update_state(self.course_key, "initial_state", allow_not_found=True)
|
||||
action_class.objects.delete(obj.id)
|
||||
with self.assertRaises(CourseActionStateItemNotFoundError):
|
||||
action_class.objects.find_first(course_key=self.course_key)
|
||||
|
||||
|
||||
@ddt
|
||||
class TestCourseActionUIStateManager(TestCourseActionStateManagerBase):
|
||||
"""
|
||||
Test class for testing the CourseActionUIStateManager.
|
||||
"""
|
||||
def init_course_action_states(self, action_class):
|
||||
"""
|
||||
Creates course action state entries with different states for the given action model class.
|
||||
Creates both displayable (should_display=True) and non-displayable (should_display=False) entries.
|
||||
"""
|
||||
def create_course_states(starting_course_num, ending_course_num, state, should_display=True):
|
||||
"""
|
||||
Creates a list of course state tuples by creating unique course locators with course-numbers
|
||||
from starting_course_num to ending_course_num.
|
||||
"""
|
||||
CourseState = namedtuple('CourseState', 'course_key, state, should_display')
|
||||
return [
|
||||
CourseState(CourseLocator("org", "course", "run" + str(num)), state, should_display)
|
||||
for num in range(starting_course_num, ending_course_num)
|
||||
]
|
||||
|
||||
NUM_COURSES_WITH_STATE1 = 3
|
||||
NUM_COURSES_WITH_STATE2 = 3
|
||||
NUM_COURSES_WITH_STATE3 = 3
|
||||
NUM_COURSES_NON_DISPLAYABLE = 3
|
||||
|
||||
# courses with state1 and should_display=True
|
||||
self.courses_with_state1 = create_course_states(
|
||||
0,
|
||||
NUM_COURSES_WITH_STATE1,
|
||||
'state1'
|
||||
)
|
||||
# courses with state2 and should_display=True
|
||||
self.courses_with_state2 = create_course_states(
|
||||
NUM_COURSES_WITH_STATE1,
|
||||
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2,
|
||||
'state2'
|
||||
)
|
||||
# courses with state3 and should_display=True
|
||||
self.courses_with_state3 = create_course_states(
|
||||
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2,
|
||||
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3,
|
||||
'state3'
|
||||
)
|
||||
# all courses with should_display=True
|
||||
self.course_actions_displayable_states = (
|
||||
self.courses_with_state1 + self.courses_with_state2 + self.courses_with_state3
|
||||
)
|
||||
# courses with state3 and should_display=False
|
||||
self.courses_with_state3_non_displayable = create_course_states(
|
||||
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3,
|
||||
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3 + NUM_COURSES_NON_DISPLAYABLE,
|
||||
'state3',
|
||||
should_display=False,
|
||||
)
|
||||
|
||||
# create course action states for all courses
|
||||
for CourseState in (self.course_actions_displayable_states + self.courses_with_state3_non_displayable):
|
||||
action_class.objects.update_state(
|
||||
CourseState.course_key,
|
||||
CourseState.state,
|
||||
should_display=CourseState.should_display,
|
||||
allow_not_found=True
|
||||
)
|
||||
|
||||
def assertCourseActionStatesEqual(self, expected, found):
|
||||
"""Asserts that the set of course keys in the expected state equal those that are found"""
|
||||
self.assertSetEqual(
|
||||
set(course_action_state.course_key for course_action_state in expected),
|
||||
set(course_action_state.course_key for course_action_state in found))
|
||||
|
||||
@data(*COURSE_ACTION_STATES)
|
||||
def test_find_all_for_display(self, action_class):
|
||||
self.init_course_action_states(action_class)
|
||||
self.assertCourseActionStatesEqual(
|
||||
self.course_actions_displayable_states,
|
||||
action_class.objects.find_all(should_display=True),
|
||||
)
|
||||
|
||||
@data(*COURSE_ACTION_STATES)
|
||||
def test_find_all_for_display_filter_exclude(self, action_class):
|
||||
self.init_course_action_states(action_class)
|
||||
for course_action_state, filter_state, exclude_state in (
|
||||
(self.courses_with_state1, 'state1', None), # filter for state1
|
||||
(self.courses_with_state2, 'state2', None), # filter for state2
|
||||
(self.courses_with_state2 + self.courses_with_state3, None, 'state1'), # exclude state1
|
||||
(self.courses_with_state1 + self.courses_with_state3, None, 'state2'), # exclude state2
|
||||
(self.courses_with_state1, 'state1', 'state2'), # filter for state1, exclude state2
|
||||
([], 'state1', 'state1'), # filter for state1, exclude state1
|
||||
):
|
||||
self.assertCourseActionStatesEqual(
|
||||
course_action_state,
|
||||
action_class.objects.find_all(
|
||||
exclude_args=({'state': exclude_state} if exclude_state else None),
|
||||
should_display=True,
|
||||
**({'state': filter_state} if filter_state else {})
|
||||
)
|
||||
)
|
||||
|
||||
def test_kwargs_in_update_state(self):
|
||||
destination_course_key = CourseLocator("org", "course", "run")
|
||||
source_course_key = CourseLocator("source_org", "source_course", "source_run")
|
||||
CourseRerunState.objects.update_state(
|
||||
course_key=destination_course_key,
|
||||
new_state='state1',
|
||||
allow_not_found=True,
|
||||
source_course_key=source_course_key,
|
||||
)
|
||||
found_action_state = CourseRerunState.objects.find_first(course_key=destination_course_key)
|
||||
self.assertEquals(source_course_key, found_action_state.source_course_key)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Tests specific to the CourseRerunState Model and Manager.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from opaque_keys.edx.locations import CourseLocator
|
||||
from course_action_state.models import CourseRerunState
|
||||
from course_action_state.managers import CourseRerunUIStateManager
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
class TestCourseRerunStateManager(TestCase):
|
||||
"""
|
||||
Test class for testing the CourseRerunUIStateManager.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.source_course_key = CourseLocator("source_org", "source_course_num", "source_run")
|
||||
self.course_key = CourseLocator("test_org", "test_course_num", "test_run")
|
||||
self.created_user = UserFactory()
|
||||
self.expected_rerun_state = {
|
||||
'created_user': self.created_user,
|
||||
'updated_user': self.created_user,
|
||||
'course_key': self.course_key,
|
||||
'action': CourseRerunUIStateManager.ACTION,
|
||||
'should_display': True,
|
||||
'message': "",
|
||||
}
|
||||
|
||||
def verify_rerun_state(self):
|
||||
"""
|
||||
Gets the rerun state object for self.course_key and verifies that the values
|
||||
of its fields equal self.expected_rerun_state.
|
||||
"""
|
||||
found_rerun = CourseRerunState.objects.find_first(course_key=self.course_key)
|
||||
found_rerun_state = {key: getattr(found_rerun, key) for key in self.expected_rerun_state}
|
||||
self.assertDictEqual(found_rerun_state, self.expected_rerun_state)
|
||||
return found_rerun
|
||||
|
||||
def dismiss_ui_and_verify(self, rerun):
|
||||
"""
|
||||
Updates the should_display field of the rerun state object for self.course_key
|
||||
and verifies its new state.
|
||||
"""
|
||||
user_who_dismisses_ui = UserFactory()
|
||||
CourseRerunState.objects.update_should_display(
|
||||
entry_id=rerun.id,
|
||||
user=user_who_dismisses_ui,
|
||||
should_display=False,
|
||||
)
|
||||
self.expected_rerun_state.update({
|
||||
'updated_user': user_who_dismisses_ui,
|
||||
'should_display': False,
|
||||
})
|
||||
self.verify_rerun_state()
|
||||
|
||||
def test_rerun_initiated(self):
|
||||
CourseRerunState.objects.initiated(
|
||||
source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user
|
||||
)
|
||||
self.expected_rerun_state.update(
|
||||
{'state': CourseRerunUIStateManager.State.IN_PROGRESS}
|
||||
)
|
||||
self.verify_rerun_state()
|
||||
|
||||
def test_rerun_succeeded(self):
|
||||
# initiate
|
||||
CourseRerunState.objects.initiated(
|
||||
source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user
|
||||
)
|
||||
|
||||
# set state to succeed
|
||||
CourseRerunState.objects.succeeded(course_key=self.course_key)
|
||||
self.expected_rerun_state.update({
|
||||
'state': CourseRerunUIStateManager.State.SUCCEEDED,
|
||||
})
|
||||
rerun = self.verify_rerun_state()
|
||||
|
||||
# dismiss ui and verify
|
||||
self.dismiss_ui_and_verify(rerun)
|
||||
|
||||
def test_rerun_failed(self):
|
||||
# initiate
|
||||
CourseRerunState.objects.initiated(
|
||||
source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user
|
||||
)
|
||||
|
||||
# set state to fail
|
||||
exception = Exception("failure in rerunning")
|
||||
CourseRerunState.objects.failed(course_key=self.course_key, exception=exception)
|
||||
self.expected_rerun_state.update({
|
||||
'state': CourseRerunUIStateManager.State.FAILED,
|
||||
'message': exception.message,
|
||||
})
|
||||
rerun = self.verify_rerun_state()
|
||||
|
||||
# dismiss ui and verify
|
||||
self.dismiss_ui_and_verify(rerun)
|
||||
@@ -396,7 +396,7 @@ class ModuleStoreWrite(ModuleStoreRead):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None):
|
||||
"""
|
||||
Sets up source_course_id to point a course with the same content as the desct_course_id. This
|
||||
operation may be cheap or expensive. It may have to copy all assets and all xblock content or
|
||||
@@ -577,7 +577,7 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
|
||||
result[field.scope][field_name] = value
|
||||
return result
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None):
|
||||
"""
|
||||
This base method just copies the assets. The lower level impls must do the actual cloning of
|
||||
content.
|
||||
@@ -585,7 +585,6 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
|
||||
# copy the assets
|
||||
if self.contentstore:
|
||||
self.contentstore.copy_all_course_assets(source_course_id, dest_course_id)
|
||||
super(ModuleStoreWriteBase, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
return dest_course_id
|
||||
|
||||
def delete_course(self, course_key, user_id):
|
||||
|
||||
@@ -288,7 +288,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
|
||||
store = self._verify_modulestore_support(None, 'create_course')
|
||||
return store.create_course(org, course, run, user_id, **kwargs)
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None):
|
||||
"""
|
||||
See the superclass for the general documentation.
|
||||
|
||||
@@ -303,16 +303,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
|
||||
# to have only course re-runs go to split. This code, however, uses the config'd priority
|
||||
dest_modulestore = self._get_modulestore_for_courseid(dest_course_id)
|
||||
if source_modulestore == dest_modulestore:
|
||||
return source_modulestore.clone_course(source_course_id, dest_course_id, user_id)
|
||||
return source_modulestore.clone_course(source_course_id, dest_course_id, user_id, fields)
|
||||
|
||||
# ensure super's only called once. The delegation above probably calls it; so, don't move
|
||||
# the invocation above the delegation call
|
||||
super(MixedModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
super(MixedModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields)
|
||||
|
||||
if dest_modulestore.get_modulestore_type() == ModuleStoreEnum.Type.split:
|
||||
split_migrator = SplitMigrator(dest_modulestore, source_modulestore)
|
||||
split_migrator.migrate_mongo_course(
|
||||
source_course_id, user_id, dest_course_id.org, dest_course_id.course, dest_course_id.run
|
||||
source_course_id, user_id, dest_course_id.org, dest_course_id.course, dest_course_id.run, fields
|
||||
)
|
||||
|
||||
def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs):
|
||||
|
||||
@@ -155,7 +155,7 @@ class DraftModuleStore(MongoModuleStore):
|
||||
course_query = self._course_key_to_son(course_key)
|
||||
self.collection.remove(course_query, multi=True)
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None):
|
||||
"""
|
||||
Only called if cloning within this store or if env doesn't set up mixed.
|
||||
* copy the courseware
|
||||
@@ -177,13 +177,20 @@ class DraftModuleStore(MongoModuleStore):
|
||||
)
|
||||
|
||||
# clone the assets
|
||||
super(DraftModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
super(DraftModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields)
|
||||
|
||||
# get the whole old course
|
||||
new_course = self.get_course(dest_course_id)
|
||||
if new_course is None:
|
||||
# create_course creates the about overview
|
||||
new_course = self.create_course(dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id)
|
||||
new_course = self.create_course(
|
||||
dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=fields
|
||||
)
|
||||
else:
|
||||
# update fields on existing course
|
||||
for key, value in fields.iteritems():
|
||||
setattr(new_course, key, value)
|
||||
self.update_item(new_course, user_id)
|
||||
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
|
||||
@@ -25,7 +25,7 @@ class SplitMigrator(object):
|
||||
self.split_modulestore = split_modulestore
|
||||
self.source_modulestore = source_modulestore
|
||||
|
||||
def migrate_mongo_course(self, source_course_key, user_id, new_org=None, new_course=None, new_run=None):
|
||||
def migrate_mongo_course(self, source_course_key, user_id, new_org=None, new_course=None, new_run=None, fields=None):
|
||||
"""
|
||||
Create a new course in split_mongo representing the published and draft versions of the course from the
|
||||
original mongo store. And return the new CourseLocator
|
||||
@@ -51,10 +51,14 @@ class SplitMigrator(object):
|
||||
new_course = source_course_key.course
|
||||
if new_run is None:
|
||||
new_run = source_course_key.run
|
||||
|
||||
new_course_key = CourseLocator(new_org, new_course, new_run, branch=ModuleStoreEnum.BranchName.published)
|
||||
new_fields = self._get_json_fields_translate_references(original_course, new_course_key, None)
|
||||
if fields:
|
||||
new_fields.update(fields)
|
||||
new_course = self.split_modulestore.create_course(
|
||||
new_org, new_course, new_run, user_id,
|
||||
fields=self._get_json_fields_translate_references(original_course, new_course_key, None),
|
||||
fields=new_fields,
|
||||
master_branch=ModuleStoreEnum.BranchName.published,
|
||||
)
|
||||
|
||||
|
||||
@@ -938,17 +938,17 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
# don't need to update the index b/c create_item did it for this version
|
||||
return xblock
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id):
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None):
|
||||
"""
|
||||
See :meth: `.ModuleStoreWrite.clone_course` for documentation.
|
||||
|
||||
In split, other than copying the assets, this is cheap as it merely creates a new version of the
|
||||
existing course.
|
||||
"""
|
||||
super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
|
||||
super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields)
|
||||
source_index = self.get_course_index_info(source_course_id)
|
||||
return self.create_course(
|
||||
dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=None, # override start_date?
|
||||
dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=fields,
|
||||
versions_dict=source_index['versions'], search_targets=source_index['search_targets']
|
||||
)
|
||||
|
||||
|
||||
@@ -1317,6 +1317,9 @@ INSTALLED_APPS = (
|
||||
|
||||
# Monitoring functionality
|
||||
'monitoring',
|
||||
|
||||
# Course action state
|
||||
'course_action_state'
|
||||
)
|
||||
|
||||
######################### MARKETING SITE ###############################
|
||||
|
||||
Reference in New Issue
Block a user