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 ...@@ -29,7 +29,7 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from opaque_keys.edx.keys import UsageKey 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.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
...@@ -46,6 +46,7 @@ from student.models import CourseEnrollment ...@@ -46,6 +46,7 @@ from student.models import CourseEnrollment
from student.roles import CourseCreatorRole, CourseInstructorRole from student.roles import CourseCreatorRole, CourseInstructorRole
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from contentstore.tests.utils import get_url from contentstore.tests.utils import get_url
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
...@@ -1561,6 +1562,107 @@ class MetadataSaveTestCase(ContentStoreTestCase): ...@@ -1561,6 +1562,107 @@ class MetadataSaveTestCase(ContentStoreTestCase):
pass 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): class EntryPageTestCase(TestCase):
""" """
Tests entry pages that aren't specific to a course. Tests entry pages that aren't specific to a course.
......
...@@ -95,6 +95,7 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -95,6 +95,7 @@ class CourseTestCase(ModuleStoreTestCase):
client = Client() client = Client()
if authenticate: if authenticate:
client.login(username=nonstaff.username, password=password) client.login(username=nonstaff.username, password=password)
nonstaff.is_authenticated = True
return client, nonstaff return client, nonstaff
def populate_course(self): def populate_course(self):
......
...@@ -9,6 +9,8 @@ from pytz import UTC ...@@ -9,6 +9,8 @@ from pytz import UTC
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse 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.contentstore.content import StaticContent
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
...@@ -16,6 +18,8 @@ from xmodule.modulestore.django import modulestore ...@@ -16,6 +18,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from opaque_keys.edx.keys import UsageKey, CourseKey from opaque_keys.edx.keys import UsageKey, CourseKey
from student.roles import CourseInstructorRole, CourseStaffRole from student.roles import CourseInstructorRole, CourseStaffRole
from student.models import CourseEnrollment
from student import auth
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -26,25 +30,58 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"} ...@@ -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]]) 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.) the various user table stuff (groups, permissions, etc.)
""" """
module_store = modulestore() module_store = modulestore()
with module_store.bulk_write_operations(course_id): with module_store.bulk_write_operations(course_key):
module_store.delete_course(course_id, user_id) module_store.delete_course(course_key, user_id)
print 'removing User permissions from course....' print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course # in the django layer, we need to remove all the user permissions groups associated with this course
try: try:
staff_role = CourseStaffRole(course_id) remove_all_instructors(course_key)
staff_role.remove_users(*staff_role.users_with_role())
instructor_role = CourseInstructorRole(course_id)
instructor_role.remove_users(*instructor_role.users_with_role())
except Exception as err: 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): def get_lms_link_for_item(location, preview=False):
...@@ -64,19 +101,19 @@ def get_lms_link_for_item(location, preview=False): ...@@ -64,19 +101,19 @@ def get_lms_link_for_item(location, preview=False):
else: else:
lms_base = settings.LMS_BASE 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, 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(), 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. 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 settings.FEATURES.get('ENABLE_MKTG_SITE', False):
if not hasattr(settings, 'MKTG_URLS'): if not hasattr(settings, 'MKTG_URLS'):
...@@ -101,9 +138,9 @@ def get_lms_link_for_about_page(course_id): ...@@ -101,9 +138,9 @@ def get_lms_link_for_about_page(course_id):
else: else:
return None 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, about_base_url=about_base,
course_id=course_id.to_deprecated_string() course_key=course_key.to_deprecated_string()
) )
......
"""
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 = ( ...@@ -547,6 +547,9 @@ INSTALLED_APPS = (
# Monitoring signals # Monitoring signals
'monitoring', '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): ...@@ -396,7 +396,7 @@ class ModuleStoreWrite(ModuleStoreRead):
pass pass
@abstractmethod @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 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 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): ...@@ -577,7 +577,7 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
result[field.scope][field_name] = value result[field.scope][field_name] = value
return result 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 This base method just copies the assets. The lower level impls must do the actual cloning of
content. content.
...@@ -585,7 +585,6 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): ...@@ -585,7 +585,6 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
# copy the assets # copy the assets
if self.contentstore: if self.contentstore:
self.contentstore.copy_all_course_assets(source_course_id, dest_course_id) 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 return dest_course_id
def delete_course(self, course_key, user_id): def delete_course(self, course_key, user_id):
......
...@@ -288,7 +288,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -288,7 +288,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
store = self._verify_modulestore_support(None, 'create_course') store = self._verify_modulestore_support(None, 'create_course')
return store.create_course(org, course, run, user_id, **kwargs) 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. See the superclass for the general documentation.
...@@ -303,16 +303,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -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 # 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) dest_modulestore = self._get_modulestore_for_courseid(dest_course_id)
if source_modulestore == dest_modulestore: 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 # ensure super's only called once. The delegation above probably calls it; so, don't move
# the invocation above the delegation call # 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: if dest_modulestore.get_modulestore_type() == ModuleStoreEnum.Type.split:
split_migrator = SplitMigrator(dest_modulestore, source_modulestore) split_migrator = SplitMigrator(dest_modulestore, source_modulestore)
split_migrator.migrate_mongo_course( 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): def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs):
......
...@@ -155,7 +155,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -155,7 +155,7 @@ class DraftModuleStore(MongoModuleStore):
course_query = self._course_key_to_son(course_key) course_query = self._course_key_to_son(course_key)
self.collection.remove(course_query, multi=True) 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. Only called if cloning within this store or if env doesn't set up mixed.
* copy the courseware * copy the courseware
...@@ -177,13 +177,20 @@ class DraftModuleStore(MongoModuleStore): ...@@ -177,13 +177,20 @@ class DraftModuleStore(MongoModuleStore):
) )
# clone the assets # 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 # get the whole old course
new_course = self.get_course(dest_course_id) new_course = self.get_course(dest_course_id)
if new_course is None: if new_course is None:
# create_course creates the about overview # 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 # Get all modules under this namespace which is (tag, org, course) tuple
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.published_only) modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.published_only)
......
...@@ -25,7 +25,7 @@ class SplitMigrator(object): ...@@ -25,7 +25,7 @@ class SplitMigrator(object):
self.split_modulestore = split_modulestore self.split_modulestore = split_modulestore
self.source_modulestore = source_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 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 original mongo store. And return the new CourseLocator
...@@ -51,10 +51,14 @@ class SplitMigrator(object): ...@@ -51,10 +51,14 @@ class SplitMigrator(object):
new_course = source_course_key.course new_course = source_course_key.course
if new_run is None: if new_run is None:
new_run = source_course_key.run new_run = source_course_key.run
new_course_key = CourseLocator(new_org, new_course, new_run, branch=ModuleStoreEnum.BranchName.published) 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_course = self.split_modulestore.create_course(
new_org, new_course, new_run, user_id, 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, master_branch=ModuleStoreEnum.BranchName.published,
) )
......
...@@ -938,17 +938,17 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -938,17 +938,17 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# don't need to update the index b/c create_item did it for this version # don't need to update the index b/c create_item did it for this version
return xblock 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. 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 In split, other than copying the assets, this is cheap as it merely creates a new version of the
existing course. 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) source_index = self.get_course_index_info(source_course_id)
return self.create_course( 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'] versions_dict=source_index['versions'], search_targets=source_index['search_targets']
) )
......
...@@ -1317,6 +1317,9 @@ INSTALLED_APPS = ( ...@@ -1317,6 +1317,9 @@ INSTALLED_APPS = (
# Monitoring functionality # Monitoring functionality
'monitoring', 'monitoring',
# Course action state
'course_action_state'
) )
######################### MARKETING SITE ############################### ######################### 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