From 538bec92a7c6df85eb931211613919393beded15 Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Sun, 27 Jul 2014 00:04:38 -0400 Subject: [PATCH 1/4] LMS-11137 Course Action State Django models. --- cms/envs/common.py | 3 + .../course_action_state/__init__.py | 0 .../course_action_state/managers.py | 150 +++++++++++++++++ .../migrations/0001_initial.py | 92 ++++++++++ .../migrations/__init__.py | 0 .../djangoapps/course_action_state/models.py | 114 +++++++++++++ .../tests/test_managers.py | 159 ++++++++++++++++++ .../tests/test_rerun_manager.py | 97 +++++++++++ lms/envs/common.py | 3 + 9 files changed, 618 insertions(+) create mode 100644 common/djangoapps/course_action_state/__init__.py create mode 100644 common/djangoapps/course_action_state/managers.py create mode 100644 common/djangoapps/course_action_state/migrations/0001_initial.py create mode 100644 common/djangoapps/course_action_state/migrations/__init__.py create mode 100644 common/djangoapps/course_action_state/models.py create mode 100644 common/djangoapps/course_action_state/tests/test_managers.py create mode 100644 common/djangoapps/course_action_state/tests/test_rerun_manager.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 60825a1356..45ebe2c4c0 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -547,6 +547,9 @@ INSTALLED_APPS = ( # Monitoring signals 'monitoring', + + # Course action state + 'course_action_state' ) diff --git a/common/djangoapps/course_action_state/__init__.py b/common/djangoapps/course_action_state/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_action_state/managers.py b/common/djangoapps/course_action_state/managers.py new file mode 100644 index 0000000000..12cda9a046 --- /dev/null +++ b/common/djangoapps/course_action_state/managers.py @@ -0,0 +1,150 @@ +""" +Model Managers for Course Actions +""" +from django.db import models, transaction + + +class CourseActionStateManager(models.Manager): + """ + An abstract Model Manager class for Course Action State models. + This abstract class expects child classes to define the ACTION (string) field. + """ + class Meta: + """Abstract manager class, with subclasses defining the ACTION (string) field.""" + abstract = True + + def find_all(self, exclude_args=None, **kwargs): + """ + Finds and returns all entries for this action and the given field names-and-values in kwargs. + The exclude_args dict allows excluding entries with the field names-and-values in exclude_args. + """ + return self.filter(action=self.ACTION, **kwargs).exclude(**(exclude_args or {})) # pylint: disable=no-member + + def find_first(self, exclude_args=None, **kwargs): + """ + Returns the first entry for the this action and the given fields in kwargs, if found. + The exclude_args dict allows excluding entries with the field names-and-values in exclude_args. + + Raises ItemNotFoundError if more than 1 entry is found. + + There may or may not be greater than one entry, depending on the usage pattern for this Action. + """ + objects = self.find_all(exclude_args=exclude_args, **kwargs) + if len(objects) == 0: + raise CourseActionStateItemNotFoundError( + "No entry found for action {action} with filter {filter}, excluding {exclude}".format( + action=self.ACTION, # pylint: disable=no-member + filter=kwargs, + exclude=exclude_args, + )) + else: + return objects[0] + + def delete(self, entry_id): + """ + Deletes the entry with given id. + """ + self.filter(id=entry_id).delete() + + +class CourseActionUIStateManager(CourseActionStateManager): + """ + A Model Manager subclass of the CourseActionStateManager class that is aware of UI-related fields related + to state management, including "should_display" and "message". + """ + + # add transaction protection to revert changes by get_or_create if an exception is raised before the final save. + @transaction.commit_on_success + def update_state( + self, course_key, new_state, should_display=True, message="", user=None, allow_not_found=False, **kwargs + ): + """ + Updates the state of the given course for this Action with the given data. + If allow_not_found is True, automatically creates an entry if it doesn't exist. + Raises CourseActionStateException if allow_not_found is False and an entry for the given course + for this Action doesn't exist. + """ + state_object, created = self.get_or_create(course_key=course_key, action=self.ACTION) # pylint: disable=no-member + + if created: + if allow_not_found: + state_object.created_user = user + else: + raise CourseActionStateItemNotFoundError( + "Cannot update non-existent entry for course_key {course_key} and action {action}".format( + action=self.ACTION, # pylint: disable=no-member + course_key=course_key, + )) + + # some state changes may not be user-initiated so override the user field only when provided + if user: + state_object.updated_user = user + + state_object.state = new_state + state_object.should_display = should_display + state_object.message = message + + # update any additional fields in kwargs + if kwargs: + for key, value in kwargs.iteritems(): + setattr(state_object, key, value) + + state_object.save() + return state_object + + def update_should_display(self, entry_id, user, should_display): + """ + Updates the should_display field with the given value for the entry for the given id. + """ + self.update(id=entry_id, updated_user=user, should_display=should_display) + + +class CourseRerunUIStateManager(CourseActionUIStateManager): + """ + A concrete model Manager for the Reruns Action. + """ + ACTION = "rerun" + + class State(object): + """ + An Enum class for maintaining the list of possible states for Reruns. + """ + IN_PROGRESS = "in_progress" + FAILED = "failed" + SUCCEEDED = "succeeded" + + def initiated(self, source_course_key, destination_course_key, user): + """ + To be called when a new rerun is initiated for the given course by the given user. + """ + self.update_state( + course_key=destination_course_key, + new_state=self.State.IN_PROGRESS, + user=user, + allow_not_found=True, + source_course_key=source_course_key, + ) + + def succeeded(self, course_key): + """ + To be called when an existing rerun for the given course has successfully completed. + """ + self.update_state( + course_key=course_key, + new_state=self.State.SUCCEEDED, + ) + + def failed(self, course_key, exception): + """ + To be called when an existing rerun for the given course has failed with the given exception. + """ + self.update_state( + course_key=course_key, + new_state=self.State.FAILED, + message=exception.message, + ) + + +class CourseActionStateItemNotFoundError(Exception): + """An exception class for errors specific to Course Action states.""" + pass diff --git a/common/djangoapps/course_action_state/migrations/0001_initial.py b/common/djangoapps/course_action_state/migrations/0001_initial.py new file mode 100644 index 0000000000..f222bcb25a --- /dev/null +++ b/common/djangoapps/course_action_state/migrations/0001_initial.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseRerunState' + db.create_table('course_action_state_coursererunstate', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created_time', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('updated_time', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + ('created_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='created_by_user+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])), + ('updated_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='updated_by_user+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])), + ('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('action', self.gf('django.db.models.fields.CharField')(max_length=100, db_index=True)), + ('state', self.gf('django.db.models.fields.CharField')(max_length=50)), + ('should_display', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('message', self.gf('django.db.models.fields.CharField')(max_length=1000)), + ('source_course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + )) + db.send_create_signal('course_action_state', ['CourseRerunState']) + + # Adding unique constraint on 'CourseRerunState', fields ['course_key', 'action'] + db.create_unique('course_action_state_coursererunstate', ['course_key', 'action']) + + + def backwards(self, orm): + # Removing unique constraint on 'CourseRerunState', fields ['course_key', 'action'] + db.delete_unique('course_action_state_coursererunstate', ['course_key', 'action']) + + # Deleting model 'CourseRerunState' + db.delete_table('course_action_state_coursererunstate') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'course_action_state.coursererunstate': { + 'Meta': {'unique_together': "(('course_key', 'action'),)", 'object_name': 'CourseRerunState'}, + 'action': ('django.db.models.fields.CharField', [], {'max_length': '100', 'db_index': 'True'}), + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_time': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message': ('django.db.models.fields.CharField', [], {'max_length': '1000'}), + 'should_display': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'source_course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'updated_time': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'updated_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'updated_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}) + } + } + + complete_apps = ['course_action_state'] \ No newline at end of file diff --git a/common/djangoapps/course_action_state/migrations/__init__.py b/common/djangoapps/course_action_state/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_action_state/models.py b/common/djangoapps/course_action_state/models.py new file mode 100644 index 0000000000..83e6231ae2 --- /dev/null +++ b/common/djangoapps/course_action_state/models.py @@ -0,0 +1,114 @@ +""" +Models for course action state + +If you make changes to this model, be sure to create an appropriate migration +file and check it in at the same time as your model changes. To do that, + +1. Go to the edx-platform dir +2. ./manage.py cms schemamigration course_action_state --auto description_of_your_change +3. It adds the migration file to edx-platform/common/djangoapps/course_action_state/migrations/ + +""" +from django.contrib.auth.models import User +from django.db import models +from xmodule_django.models import CourseKeyField +from course_action_state.managers import CourseActionStateManager, CourseRerunUIStateManager + + +class CourseActionState(models.Model): + """ + A django model for maintaining state data for course actions that take a long time. + For example: course copying (reruns), import, export, and validation. + """ + + class Meta: + """ + For performance reasons, we disable "concrete inheritance", by making the Model base class abstract. + With the "abstract base class" inheritance model, tables are only created for derived models, not for + the parent classes. This way, we don't have extra overhead of extra tables and joins that would + otherwise happen with the multi-table inheritance model. + """ + abstract = True + + # FIELDS + + # Created is the time this action was initiated + created_time = models.DateTimeField(auto_now_add=True) + + # Updated is the last time this entry was modified + updated_time = models.DateTimeField(auto_now=True) + + # User who initiated the course action + created_user = models.ForeignKey( + User, + # allow NULL values in case the action is not initiated by a user (e.g., a background thread) + null=True, + # set on_delete to SET_NULL to prevent this model from being deleted in the event the user is deleted + on_delete=models.SET_NULL, + # add a '+' at the end to prevent a backward relation from the User model + related_name='created_by_user+' + ) + + # User who last updated the course action + updated_user = models.ForeignKey( + User, + # allow NULL values in case the action is not updated by a user (e.g., a background thread) + null=True, + # set on_delete to SET_NULL to prevent this model from being deleted in the event the user is deleted + on_delete=models.SET_NULL, + # add a '+' at the end to prevent a backward relation from the User model + related_name='updated_by_user+' + ) + + # Course that is being acted upon + course_key = CourseKeyField(max_length=255, db_index=True) + + # Action that is being taken on the course + action = models.CharField(max_length=100, db_index=True) + + # Current state of the action. + state = models.CharField(max_length=50) + + # MANAGERS + objects = CourseActionStateManager() + + +class CourseActionUIState(CourseActionState): + """ + An abstract django model that is a sub-class of CourseActionState with additional fields related to UI. + """ + class Meta: + """ + See comment in CourseActionState on disabling "concrete inheritance". + """ + abstract = True + + # FIELDS + + # Whether or not the status should be displayed to users + should_display = models.BooleanField() + + # Message related to the status + message = models.CharField(max_length=1000) + + +# Rerun courses also need these fields. All rerun course actions will have a row here as well. +class CourseRerunState(CourseActionUIState): + """ + A concrete django model for maintaining state specifically for the Action Course Reruns. + """ + class Meta: + """ + Set the (destination) course_key field to be unique for the rerun action + Although multiple reruns can be in progress simultaneously for a particular source course_key, + only a single rerun action can be in progress for the destination course_key. + """ + unique_together = ("course_key", "action") + + # FIELDS + # Original course that is being rerun + source_course_key = CourseKeyField(max_length=255, db_index=True) + + # MANAGERS + # Override the abstract class' manager with a Rerun-specific manager that inherits from the base class' manager. + objects = CourseRerunUIStateManager() diff --git a/common/djangoapps/course_action_state/tests/test_managers.py b/common/djangoapps/course_action_state/tests/test_managers.py new file mode 100644 index 0000000000..8c2ee847bd --- /dev/null +++ b/common/djangoapps/course_action_state/tests/test_managers.py @@ -0,0 +1,159 @@ +# pylint: disable=invalid-name, attribute-defined-outside-init +""" +Tests for basic common operations related to Course Action State managers +""" +from ddt import ddt, data +from django.test import TestCase +from collections import namedtuple +from opaque_keys.edx.locations import CourseLocator +from course_action_state.models import CourseRerunState +from course_action_state.managers import CourseActionStateItemNotFoundError + + +# Sequence of Action models to be tested with ddt. +COURSE_ACTION_STATES = (CourseRerunState, ) + + +class TestCourseActionStateManagerBase(TestCase): + """ + Base class for testing Course Action State Managers. + """ + def setUp(self): + self.course_key = CourseLocator("test_org", "test_course_num", "test_run") + + +@ddt +class TestCourseActionStateManager(TestCourseActionStateManagerBase): + """ + Test class for testing the CourseActionStateManager. + """ + @data(*COURSE_ACTION_STATES) + def test_update_state_allow_not_found_is_false(self, action_class): + with self.assertRaises(CourseActionStateItemNotFoundError): + action_class.objects.update_state(self.course_key, "fake_state", allow_not_found=False) + + @data(*COURSE_ACTION_STATES) + def test_update_state_allow_not_found(self, action_class): + action_class.objects.update_state(self.course_key, "initial_state", allow_not_found=True) + self.assertIsNotNone( + action_class.objects.find_first(course_key=self.course_key) + ) + + @data(*COURSE_ACTION_STATES) + def test_delete(self, action_class): + obj = action_class.objects.update_state(self.course_key, "initial_state", allow_not_found=True) + action_class.objects.delete(obj.id) + with self.assertRaises(CourseActionStateItemNotFoundError): + action_class.objects.find_first(course_key=self.course_key) + + +@ddt +class TestCourseActionUIStateManager(TestCourseActionStateManagerBase): + """ + Test class for testing the CourseActionUIStateManager. + """ + def init_course_action_states(self, action_class): + """ + Creates course action state entries with different states for the given action model class. + Creates both displayable (should_display=True) and non-displayable (should_display=False) entries. + """ + def create_course_states(starting_course_num, ending_course_num, state, should_display=True): + """ + Creates a list of course state tuples by creating unique course locators with course-numbers + from starting_course_num to ending_course_num. + """ + CourseState = namedtuple('CourseState', 'course_key, state, should_display') + return [ + CourseState(CourseLocator("org", "course", "run" + str(num)), state, should_display) + for num in range(starting_course_num, ending_course_num) + ] + + NUM_COURSES_WITH_STATE1 = 3 + NUM_COURSES_WITH_STATE2 = 3 + NUM_COURSES_WITH_STATE3 = 3 + NUM_COURSES_NON_DISPLAYABLE = 3 + + # courses with state1 and should_display=True + self.courses_with_state1 = create_course_states( + 0, + NUM_COURSES_WITH_STATE1, + 'state1' + ) + # courses with state2 and should_display=True + self.courses_with_state2 = create_course_states( + NUM_COURSES_WITH_STATE1, + NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2, + 'state2' + ) + # courses with state3 and should_display=True + self.courses_with_state3 = create_course_states( + NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2, + NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3, + 'state3' + ) + # all courses with should_display=True + self.course_actions_displayable_states = ( + self.courses_with_state1 + self.courses_with_state2 + self.courses_with_state3 + ) + # courses with state3 and should_display=False + self.courses_with_state3_non_displayable = create_course_states( + NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3, + NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3 + NUM_COURSES_NON_DISPLAYABLE, + 'state3', + should_display=False, + ) + + # create course action states for all courses + for CourseState in (self.course_actions_displayable_states + self.courses_with_state3_non_displayable): + action_class.objects.update_state( + CourseState.course_key, + CourseState.state, + should_display=CourseState.should_display, + allow_not_found=True + ) + + def assertCourseActionStatesEqual(self, expected, found): + """Asserts that the set of course keys in the expected state equal those that are found""" + self.assertSetEqual( + set(course_action_state.course_key for course_action_state in expected), + set(course_action_state.course_key for course_action_state in found)) + + @data(*COURSE_ACTION_STATES) + def test_find_all_for_display(self, action_class): + self.init_course_action_states(action_class) + self.assertCourseActionStatesEqual( + self.course_actions_displayable_states, + action_class.objects.find_all(should_display=True), + ) + + @data(*COURSE_ACTION_STATES) + def test_find_all_for_display_filter_exclude(self, action_class): + self.init_course_action_states(action_class) + for course_action_state, filter_state, exclude_state in ( + (self.courses_with_state1, 'state1', None), # filter for state1 + (self.courses_with_state2, 'state2', None), # filter for state2 + (self.courses_with_state2 + self.courses_with_state3, None, 'state1'), # exclude state1 + (self.courses_with_state1 + self.courses_with_state3, None, 'state2'), # exclude state2 + (self.courses_with_state1, 'state1', 'state2'), # filter for state1, exclude state2 + ([], 'state1', 'state1'), # filter for state1, exclude state1 + ): + self.assertCourseActionStatesEqual( + course_action_state, + action_class.objects.find_all( + exclude_args=({'state': exclude_state} if exclude_state else None), + should_display=True, + **({'state': filter_state} if filter_state else {}) + ) + ) + + def test_kwargs_in_update_state(self): + destination_course_key = CourseLocator("org", "course", "run") + source_course_key = CourseLocator("source_org", "source_course", "source_run") + CourseRerunState.objects.update_state( + course_key=destination_course_key, + new_state='state1', + allow_not_found=True, + source_course_key=source_course_key, + ) + found_action_state = CourseRerunState.objects.find_first(course_key=destination_course_key) + self.assertEquals(source_course_key, found_action_state.source_course_key) diff --git a/common/djangoapps/course_action_state/tests/test_rerun_manager.py b/common/djangoapps/course_action_state/tests/test_rerun_manager.py new file mode 100644 index 0000000000..92dfb7dcc0 --- /dev/null +++ b/common/djangoapps/course_action_state/tests/test_rerun_manager.py @@ -0,0 +1,97 @@ +""" +Tests specific to the CourseRerunState Model and Manager. +""" + +from django.test import TestCase +from opaque_keys.edx.locations import CourseLocator +from course_action_state.models import CourseRerunState +from course_action_state.managers import CourseRerunUIStateManager +from student.tests.factories import UserFactory + + +class TestCourseRerunStateManager(TestCase): + """ + Test class for testing the CourseRerunUIStateManager. + """ + def setUp(self): + self.source_course_key = CourseLocator("source_org", "source_course_num", "source_run") + self.course_key = CourseLocator("test_org", "test_course_num", "test_run") + self.created_user = UserFactory() + self.expected_rerun_state = { + 'created_user': self.created_user, + 'updated_user': self.created_user, + 'course_key': self.course_key, + 'action': CourseRerunUIStateManager.ACTION, + 'should_display': True, + 'message': "", + } + + def verify_rerun_state(self): + """ + Gets the rerun state object for self.course_key and verifies that the values + of its fields equal self.expected_rerun_state. + """ + found_rerun = CourseRerunState.objects.find_first(course_key=self.course_key) + found_rerun_state = {key: getattr(found_rerun, key) for key in self.expected_rerun_state} + self.assertDictEqual(found_rerun_state, self.expected_rerun_state) + return found_rerun + + def dismiss_ui_and_verify(self, rerun): + """ + Updates the should_display field of the rerun state object for self.course_key + and verifies its new state. + """ + user_who_dismisses_ui = UserFactory() + CourseRerunState.objects.update_should_display( + entry_id=rerun.id, + user=user_who_dismisses_ui, + should_display=False, + ) + self.expected_rerun_state.update({ + 'updated_user': user_who_dismisses_ui, + 'should_display': False, + }) + self.verify_rerun_state() + + def test_rerun_initiated(self): + CourseRerunState.objects.initiated( + source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user + ) + self.expected_rerun_state.update( + {'state': CourseRerunUIStateManager.State.IN_PROGRESS} + ) + self.verify_rerun_state() + + def test_rerun_succeeded(self): + # initiate + CourseRerunState.objects.initiated( + source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user + ) + + # set state to succeed + CourseRerunState.objects.succeeded(course_key=self.course_key) + self.expected_rerun_state.update({ + 'state': CourseRerunUIStateManager.State.SUCCEEDED, + }) + rerun = self.verify_rerun_state() + + # dismiss ui and verify + self.dismiss_ui_and_verify(rerun) + + def test_rerun_failed(self): + # initiate + CourseRerunState.objects.initiated( + source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user + ) + + # set state to fail + exception = Exception("failure in rerunning") + CourseRerunState.objects.failed(course_key=self.course_key, exception=exception) + self.expected_rerun_state.update({ + 'state': CourseRerunUIStateManager.State.FAILED, + 'message': exception.message, + }) + rerun = self.verify_rerun_state() + + # dismiss ui and verify + self.dismiss_ui_and_verify(rerun) diff --git a/lms/envs/common.py b/lms/envs/common.py index c6f6598e68..e201b81863 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1317,6 +1317,9 @@ INSTALLED_APPS = ( # Monitoring functionality 'monitoring', + + # Course action state + 'course_action_state' ) ######################### MARKETING SITE ############################### From fe4bce8bba0ead6768d2ac3484d6967d8c3feffb Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Sun, 27 Jul 2014 18:20:51 -0400 Subject: [PATCH 2/4] clone_course: Add fields parameter to support new display_name. --- common/lib/xmodule/xmodule/modulestore/__init__.py | 5 ++--- common/lib/xmodule/xmodule/modulestore/mixed.py | 8 ++++---- .../lib/xmodule/xmodule/modulestore/mongo/draft.py | 13 ++++++++++--- .../xmodule/xmodule/modulestore/split_migrator.py | 8 ++++++-- .../xmodule/modulestore/split_mongo/split.py | 6 +++--- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index f7af93dace..f21507d185 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -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): diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 2e067c3c9d..85e3323b56 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -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): diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py index 66c2de808a..cd7e0bc4c2 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py @@ -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) diff --git a/common/lib/xmodule/xmodule/modulestore/split_migrator.py b/common/lib/xmodule/xmodule/modulestore/split_migrator.py index 17e92db137..1f3d66a123 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/split_migrator.py @@ -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, ) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 6fb0da75db..6683acd8e0 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -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'] ) From 88ad29d1666da8d905bddc10287a7256ff791ee8 Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Sun, 27 Jul 2014 21:33:23 -0400 Subject: [PATCH 3/4] LMS-11016 Studio server-side for course_listing and course_rerun. Conflicts: cms/djangoapps/contentstore/tests/test_course_listing.py --- .../contentstore/tests/test_contentstore.py | 104 +++++++- .../contentstore/tests/test_course_listing.py | 78 ++++-- cms/djangoapps/contentstore/tests/utils.py | 1 + cms/djangoapps/contentstore/utils.py | 67 ++++-- cms/djangoapps/contentstore/views/course.py | 227 ++++++++++-------- cms/djangoapps/contentstore/views/tasks.py | 32 +++ 6 files changed, 373 insertions(+), 136 deletions(-) create mode 100644 cms/djangoapps/contentstore/views/tasks.py diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 41702b64df..101533a467 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -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 ' Date: Tue, 29 Jul 2014 15:27:10 -0400 Subject: [PATCH 4/4] Fixup: Studio server-side - review comments. --- cms/djangoapps/contentstore/views/course.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 736965777e..0f74fd41b2 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -172,13 +172,6 @@ def _accessible_courses_list(request): """ List all courses available to the logged in user by iterating through all the courses """ - def course_permission_filter(course_key): - """Filter out courses that user doesn't have access to""" - if GlobalStaff().has_user(request.user): - return True - else: - return has_course_access(request.user, course_key) - def course_filter(course): """ Filter out unusable and inaccessible courses @@ -191,15 +184,15 @@ def _accessible_courses_list(request): if course.location.course == 'templates': return False - return course_permission_filter(course.id) + return has_course_access(request.user, course.id) courses = filter(course_filter, modulestore().get_courses()) unsucceeded_course_actions = [ - crs for crs in + course for course in CourseRerunState.objects.find_all( exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, should_display=True ) - if course_permission_filter(crs.course_key) + if has_course_access(request.user, course.course_key) ] return courses, unsucceeded_course_actions