Commit bac223ac by noraiz-anwar

Enhance delete_course management command

parent 484ec256
...@@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand, CommandError ...@@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand, CommandError
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from contentstore.utils import delete_course_and_groups from contentstore.utils import delete_course
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -21,12 +21,36 @@ from .prompt import query_yes_no ...@@ -21,12 +21,36 @@ from .prompt import query_yes_no
class Command(BaseCommand): class Command(BaseCommand):
""" """
Delete a MongoDB backed course Delete a MongoDB backed course
Example usage:
$ ./manage.py cms delete_course 'course-v1:edX+DemoX+Demo_Course' --settings=devstack
$ ./manage.py cms delete_course 'course-v1:edX+DemoX+Demo_Course' --keep-instructors --settings=devstack
Note:
keep-instructors option is added in effort to delete duplicate courses safely.
There happens to be courses with difference of casing in ids, for example
course-v1:DartmouthX+DART.ENGL.01.X+2016_T1 is a duplicate of course-v1:DartmouthX+DART.ENGL.01.x+2016_T1
(Note the differene in 'x' of course number). These two are independent courses in MongoDB.
Current MYSQL setup is case-insensitive which essentially means there are not
seperate entries (in all course related mysql tables, but here we are concerned about accesses)
for duplicate courses.
This option will make us able to delete course (duplicate one) from
mongo while perserving course's related access data in mysql.
""" """
help = '''Delete a MongoDB backed course''' help = '''Delete a MongoDB backed course'''
def add_arguments(self, parser): def add_arguments(self, parser):
"""
Add arguments to the command parser.
"""
parser.add_argument('course_key', help="ID of the course to delete.") parser.add_argument('course_key', help="ID of the course to delete.")
parser.add_argument(
'--keep-instructors',
action='store_true',
default=False,
help='Do not remove permissions of users and groups for course',
)
def handle(self, *args, **options): def handle(self, *args, **options):
try: try:
course_key = CourseKey.from_string(options['course_key']) course_key = CourseKey.from_string(options['course_key'])
...@@ -39,5 +63,5 @@ class Command(BaseCommand): ...@@ -39,5 +63,5 @@ class Command(BaseCommand):
print 'Going to delete the %s course from DB....' % options['course_key'] print 'Going to delete the %s course from DB....' % options['course_key']
if query_yes_no("Deleting course {0}. Confirm?".format(course_key), default="no"): if query_yes_no("Deleting course {0}. Confirm?".format(course_key), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"): if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
delete_course_and_groups(course_key, ModuleStoreEnum.UserID.mgmt_command) delete_course(course_key, ModuleStoreEnum.UserID.mgmt_command, options['keep_instructors'])
print "Deleted course {}".format(course_key) print "Deleted course {}".format(course_key)
...@@ -6,9 +6,11 @@ import mock ...@@ -6,9 +6,11 @@ import mock
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django.core.management import call_command, CommandError from django.core.management import call_command, CommandError
from django.contrib.auth.models import User
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from student.roles import CourseInstructorRole
class DeleteCourseTest(CourseTestCase): class DeleteCourseTest(CourseTestCase):
...@@ -60,3 +62,27 @@ class DeleteCourseTest(CourseTestCase): ...@@ -60,3 +62,27 @@ class DeleteCourseTest(CourseTestCase):
patched_yes_no.return_value = True patched_yes_no.return_value = True
call_command('delete_course', 'TestX/TS01/2015_Q1') call_command('delete_course', 'TestX/TS01/2015_Q1')
self.assertIsNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1"))) self.assertIsNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1")))
def test_course_deletion_with_keep_instructors(self):
"""
Tests that deleting course with keep-instructors option do not remove instructors from course.
"""
instructor_user = User.objects.create(
username='test_instructor',
email='test_email@example.com'
)
self.assertIsNotNone(instructor_user)
# Add and verify instructor role for the course
instructor_role = CourseInstructorRole(self.course.id)
instructor_role.add_users(instructor_user)
self.assertTrue(instructor_role.has_user(instructor_user))
# Verify the course we are about to delete exists in the modulestore
self.assertIsNotNone(modulestore().get_course(self.course.id))
with mock.patch(self.YESNO_PATCH_LOCATION, return_value=True):
call_command('delete_course', 'TestX/TS01/2015_Q1', '--keep-instructors')
self.assertIsNone(modulestore().get_course(self.course.id))
self.assertTrue(instructor_role.has_user(instructor_user))
...@@ -26,7 +26,7 @@ from path import Path as path ...@@ -26,7 +26,7 @@ from path import Path as path
from common.test.utils import XssTestMixin from common.test.utils import XssTestMixin
from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, get_url, parse_json from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, get_url, parse_json
from contentstore.utils import delete_course_and_groups, reverse_course_url, reverse_url from contentstore.utils import delete_course, reverse_course_url, reverse_url
from contentstore.views.component import ADVANCED_COMPONENT_TYPES from contentstore.views.component import ADVANCED_COMPONENT_TYPES
from course_action_state.managers import CourseActionStateItemNotFoundError from course_action_state.managers import CourseActionStateItemNotFoundError
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
...@@ -1223,7 +1223,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1223,7 +1223,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
test_course_data = self.assert_created_course(number_suffix=uuid4().hex) test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
course_id = _get_course_id(self.store, test_course_data) course_id = _get_course_id(self.store, test_course_data)
self.assertTrue(are_permissions_roles_seeded(course_id)) self.assertTrue(are_permissions_roles_seeded(course_id))
delete_course_and_groups(course_id, self.user.id) delete_course(course_id, self.user.id)
# should raise an exception for checking permissions on deleted course # should raise an exception for checking permissions on deleted course
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
are_permissions_roles_seeded(course_id) are_permissions_roles_seeded(course_id)
...@@ -1235,7 +1235,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1235,7 +1235,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
# unseed the forums for the first course # unseed the forums for the first course
course_id = _get_course_id(self.store, test_course_data) course_id = _get_course_id(self.store, test_course_data)
delete_course_and_groups(course_id, self.user.id) delete_course(course_id, self.user.id)
# should raise an exception for checking permissions on deleted course # should raise an exception for checking permissions on deleted course
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
are_permissions_roles_seeded(course_id) are_permissions_roles_seeded(course_id)
...@@ -1255,7 +1255,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1255,7 +1255,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id)) self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id))
self.assertTrue(self.user.roles.filter(name="Student", course_id=course_id)) self.assertTrue(self.user.roles.filter(name="Student", course_id=course_id))
delete_course_and_groups(course_id, self.user.id) delete_course(course_id, self.user.id)
# check that user's enrollment for this course is not deleted # check that user's enrollment for this course is not deleted
self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id)) self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id))
# check that user has form role "Student" for this course even after deleting it # check that user has form role "Student" for this course even after deleting it
...@@ -1277,7 +1277,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1277,7 +1277,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
self.assertGreater(len(instructor_role.users_with_role()), 0) self.assertGreater(len(instructor_role.users_with_role()), 0)
# Now delete course and check that user not in instructor groups of this course # Now delete course and check that user not in instructor groups of this course
delete_course_and_groups(course_id, self.user.id) delete_course(course_id, self.user.id)
# Update our cached user since its roles have changed # Update our cached user since its roles have changed
self.user = User.objects.get_by_natural_key(self.user.natural_key()[0]) self.user = User.objects.get_by_natural_key(self.user.natural_key()[0])
...@@ -1285,6 +1285,26 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1285,6 +1285,26 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
self.assertFalse(instructor_role.has_user(self.user)) self.assertFalse(instructor_role.has_user(self.user))
self.assertEqual(len(instructor_role.users_with_role()), 0) self.assertEqual(len(instructor_role.users_with_role()), 0)
def test_delete_course_with_keep_instructors(self):
"""
Tests that when you delete a course with 'keep_instructors',
it does not remove any permissions of users/groups from the course
"""
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
course_id = _get_course_id(self.store, test_course_data)
# Add and verify instructor role for the course
instructor_role = CourseInstructorRole(course_id)
instructor_role.add_users(self.user)
self.assertTrue(instructor_role.has_user(self.user))
delete_course(course_id, self.user.id, keep_instructors=True)
# Update our cached user so if any change in roles can be captured
self.user = User.objects.get_by_natural_key(self.user.natural_key()[0])
self.assertTrue(instructor_role.has_user(self.user))
def test_create_course_after_delete(self): def test_create_course_after_delete(self):
""" """
Test that course creation works after deleting a course with the same URL Test that course creation works after deleting a course with the same URL
...@@ -1292,7 +1312,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1292,7 +1312,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
test_course_data = self.assert_created_course() test_course_data = self.assert_created_course()
course_id = _get_course_id(self.store, test_course_data) course_id = _get_course_id(self.store, test_course_data)
delete_course_and_groups(course_id, self.user.id) delete_course(course_id, self.user.id)
self.assert_created_course() self.assert_created_course()
......
...@@ -15,7 +15,7 @@ from opaque_keys.edx.locations import CourseLocator ...@@ -15,7 +15,7 @@ from opaque_keys.edx.locations import CourseLocator
from common.test.utils import XssTestMixin from common.test.utils import XssTestMixin
from contentstore.tests.utils import AjaxEnabledTestClient from contentstore.tests.utils import AjaxEnabledTestClient
from contentstore.utils import delete_course_and_groups from contentstore.utils import delete_course
from contentstore.views.course import ( from contentstore.views.course import (
AccessListFallback, AccessListFallback,
_accessible_courses_iter, _accessible_courses_iter,
...@@ -298,7 +298,7 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin): ...@@ -298,7 +298,7 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin):
self.assertEqual(courses_list, courses_list_by_groups) self.assertEqual(courses_list, courses_list_by_groups)
# now delete this course and re-add user to instructor group of this course # now delete this course and re-add user to instructor group of this course
delete_course_and_groups(course_key, self.user.id) delete_course(course_key, self.user.id)
CourseInstructorRole(course_key).add_users(self.user) CourseInstructorRole(course_key).add_users(self.user)
......
...@@ -3,7 +3,7 @@ Unit tests for checking default forum role "Student" of a user when he creates a ...@@ -3,7 +3,7 @@ Unit tests for checking default forum role "Student" of a user when he creates a
after deleting it creates same course again after deleting it creates same course again
""" """
from contentstore.tests.utils import AjaxEnabledTestClient from contentstore.tests.utils import AjaxEnabledTestClient
from contentstore.utils import delete_course_and_groups, reverse_url from contentstore.utils import delete_course, reverse_url
from courseware.tests.factories import UserFactory from courseware.tests.factories import UserFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
...@@ -60,7 +60,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase): ...@@ -60,7 +60,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase):
# check that user has his default "Student" forum role for this course # check that user has his default "Student" forum role for this course
self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key))
delete_course_and_groups(self.course_key, self.user.id) delete_course(self.course_key, self.user.id)
# check that user's enrollment for this course is not deleted # check that user's enrollment for this course is not deleted
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key))
...@@ -78,7 +78,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase): ...@@ -78,7 +78,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase):
self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key))
# delete this course and recreate this course with same user # delete this course and recreate this course with same user
delete_course_and_groups(self.course_key, self.user.id) delete_course(self.course_key, self.user.id)
resp = self._create_course_with_given_location(self.course_key) resp = self._create_course_with_given_location(self.course_key)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
...@@ -96,7 +96,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase): ...@@ -96,7 +96,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase):
# check that user has enrollment and his default "Student" forum role for this course # check that user has enrollment and his default "Student" forum role for this course
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key))
# delete this course and recreate this course with same user # delete this course and recreate this course with same user
delete_course_and_groups(self.course_key, self.user.id) delete_course(self.course_key, self.user.id)
# now create same course with different name case ('uppercase') # now create same course with different name case ('uppercase')
new_course_key = self.course_key.replace(course=self.course_key.course.upper()) new_course_key = self.course_key.replace(course=self.course_key.course.upper())
......
...@@ -61,22 +61,38 @@ def remove_all_instructors(course_key): ...@@ -61,22 +61,38 @@ def remove_all_instructors(course_key):
instructor_role.remove_users(*instructor_role.users_with_role()) instructor_role.remove_users(*instructor_role.users_with_role())
def delete_course_and_groups(course_key, user_id): def delete_course(course_key, user_id, keep_instructors=False):
""" """
This deletes the courseware associated with a course_key as well as cleaning update_item Delete course from module store and if specified remove user and
the various user table stuff (groups, permissions, etc.) groups permissions from course.
"""
_delete_course_from_modulestore(course_key, user_id)
if not keep_instructors:
_remove_instructors(course_key)
def _delete_course_from_modulestore(course_key, user_id):
"""
Delete course from MongoDB. Deleting course will fire a signal which will result into
deletion of the courseware associated with a course_key.
""" """
module_store = modulestore() module_store = modulestore()
with module_store.bulk_operations(course_key): with module_store.bulk_operations(course_key):
module_store.delete_course(course_key, user_id) 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 def _remove_instructors(course_key):
try: """
remove_all_instructors(course_key) In the django layer, remove all the user/groups permissions associated with this course
except Exception as err: """
log.error("Error in deleting course groups for {0}: {1}".format(course_key, err)) print 'removing User permissions from course....'
try:
remove_all_instructors(course_key)
except Exception as err:
log.error("Error in deleting course groups for {0}: {1}".format(course_key, err))
def get_lms_link_for_item(location, preview=False): def get_lms_link_for_item(location, preview=False):
......
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