From 538bec92a7c6df85eb931211613919393beded15 Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Sun, 27 Jul 2014 00:04:38 -0400 Subject: [PATCH] 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 ###############################