Commit 0181bb2d by Nimisha Asthagiri

Merge pull request #4574 from edx/reruns/cms-server-side

Reruns/cms server side LMS-11016
parents 5afd2eff 86986e63
......@@ -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
......
"""
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'
)
......
"""
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
# -*- 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']
\ No newline at end of file
"""
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()
# 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)
"""
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 ###############################
......
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