Commit 538bec92 by Nimisha Asthagiri

LMS-11137 Course Action State Django models.

parent 919e5303
......@@ -547,6 +547,9 @@ INSTALLED_APPS = (
# Monitoring signals
'monitoring',
# Course action state
'course_action_state'
)
......
"""
Model Managers for Course Actions
"""
from django.db import models, transaction
class CourseActionStateManager(models.Manager):
"""
An abstract Model Manager class for Course Action State models.
This abstract class expects child classes to define the ACTION (string) field.
"""
class Meta:
"""Abstract manager class, with subclasses defining the ACTION (string) field."""
abstract = True
def find_all(self, exclude_args=None, **kwargs):
"""
Finds and returns all entries for this action and the given field names-and-values in kwargs.
The exclude_args dict allows excluding entries with the field names-and-values in exclude_args.
"""
return self.filter(action=self.ACTION, **kwargs).exclude(**(exclude_args or {})) # pylint: disable=no-member
def find_first(self, exclude_args=None, **kwargs):
"""
Returns the first entry for the this action and the given fields in kwargs, if found.
The exclude_args dict allows excluding entries with the field names-and-values in exclude_args.
Raises ItemNotFoundError if more than 1 entry is found.
There may or may not be greater than one entry, depending on the usage pattern for this Action.
"""
objects = self.find_all(exclude_args=exclude_args, **kwargs)
if len(objects) == 0:
raise CourseActionStateItemNotFoundError(
"No entry found for action {action} with filter {filter}, excluding {exclude}".format(
action=self.ACTION, # pylint: disable=no-member
filter=kwargs,
exclude=exclude_args,
))
else:
return objects[0]
def delete(self, entry_id):
"""
Deletes the entry with given id.
"""
self.filter(id=entry_id).delete()
class CourseActionUIStateManager(CourseActionStateManager):
"""
A Model Manager subclass of the CourseActionStateManager class that is aware of UI-related fields related
to state management, including "should_display" and "message".
"""
# add transaction protection to revert changes by get_or_create if an exception is raised before the final save.
@transaction.commit_on_success
def update_state(
self, course_key, new_state, should_display=True, message="", user=None, allow_not_found=False, **kwargs
):
"""
Updates the state of the given course for this Action with the given data.
If allow_not_found is True, automatically creates an entry if it doesn't exist.
Raises CourseActionStateException if allow_not_found is False and an entry for the given course
for this Action doesn't exist.
"""
state_object, created = self.get_or_create(course_key=course_key, action=self.ACTION) # pylint: disable=no-member
if created:
if allow_not_found:
state_object.created_user = user
else:
raise CourseActionStateItemNotFoundError(
"Cannot update non-existent entry for course_key {course_key} and action {action}".format(
action=self.ACTION, # pylint: disable=no-member
course_key=course_key,
))
# some state changes may not be user-initiated so override the user field only when provided
if user:
state_object.updated_user = user
state_object.state = new_state
state_object.should_display = should_display
state_object.message = message
# update any additional fields in kwargs
if kwargs:
for key, value in kwargs.iteritems():
setattr(state_object, key, value)
state_object.save()
return state_object
def update_should_display(self, entry_id, user, should_display):
"""
Updates the should_display field with the given value for the entry for the given id.
"""
self.update(id=entry_id, updated_user=user, should_display=should_display)
class CourseRerunUIStateManager(CourseActionUIStateManager):
"""
A concrete model Manager for the Reruns Action.
"""
ACTION = "rerun"
class State(object):
"""
An Enum class for maintaining the list of possible states for Reruns.
"""
IN_PROGRESS = "in_progress"
FAILED = "failed"
SUCCEEDED = "succeeded"
def initiated(self, source_course_key, destination_course_key, user):
"""
To be called when a new rerun is initiated for the given course by the given user.
"""
self.update_state(
course_key=destination_course_key,
new_state=self.State.IN_PROGRESS,
user=user,
allow_not_found=True,
source_course_key=source_course_key,
)
def succeeded(self, course_key):
"""
To be called when an existing rerun for the given course has successfully completed.
"""
self.update_state(
course_key=course_key,
new_state=self.State.SUCCEEDED,
)
def failed(self, course_key, exception):
"""
To be called when an existing rerun for the given course has failed with the given exception.
"""
self.update_state(
course_key=course_key,
new_state=self.State.FAILED,
message=exception.message,
)
class CourseActionStateItemNotFoundError(Exception):
"""An exception class for errors specific to Course Action states."""
pass
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseRerunState'
db.create_table('course_action_state_coursererunstate', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created_time', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('updated_time', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
('created_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='created_by_user+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])),
('updated_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='updated_by_user+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])),
('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
('action', self.gf('django.db.models.fields.CharField')(max_length=100, db_index=True)),
('state', self.gf('django.db.models.fields.CharField')(max_length=50)),
('should_display', self.gf('django.db.models.fields.BooleanField')(default=False)),
('message', self.gf('django.db.models.fields.CharField')(max_length=1000)),
('source_course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
))
db.send_create_signal('course_action_state', ['CourseRerunState'])
# Adding unique constraint on 'CourseRerunState', fields ['course_key', 'action']
db.create_unique('course_action_state_coursererunstate', ['course_key', 'action'])
def backwards(self, orm):
# Removing unique constraint on 'CourseRerunState', fields ['course_key', 'action']
db.delete_unique('course_action_state_coursererunstate', ['course_key', 'action'])
# Deleting model 'CourseRerunState'
db.delete_table('course_action_state_coursererunstate')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'course_action_state.coursererunstate': {
'Meta': {'unique_together': "(('course_key', 'action'),)", 'object_name': 'CourseRerunState'},
'action': ('django.db.models.fields.CharField', [], {'max_length': '100', 'db_index': 'True'}),
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created_time': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'created_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'message': ('django.db.models.fields.CharField', [], {'max_length': '1000'}),
'should_display': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'source_course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'state': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'updated_time': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'updated_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'updated_by_user+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"})
}
}
complete_apps = ['course_action_state']
\ No newline at end of file
"""
Models for course action state
If you make changes to this model, be sure to create an appropriate migration
file and check it in at the same time as your model changes. To do that,
1. Go to the edx-platform dir
2. ./manage.py cms schemamigration course_action_state --auto description_of_your_change
3. It adds the migration file to edx-platform/common/djangoapps/course_action_state/migrations/
"""
from django.contrib.auth.models import User
from django.db import models
from xmodule_django.models import CourseKeyField
from course_action_state.managers import CourseActionStateManager, CourseRerunUIStateManager
class CourseActionState(models.Model):
"""
A django model for maintaining state data for course actions that take a long time.
For example: course copying (reruns), import, export, and validation.
"""
class Meta:
"""
For performance reasons, we disable "concrete inheritance", by making the Model base class abstract.
With the "abstract base class" inheritance model, tables are only created for derived models, not for
the parent classes. This way, we don't have extra overhead of extra tables and joins that would
otherwise happen with the multi-table inheritance model.
"""
abstract = True
# FIELDS
# Created is the time this action was initiated
created_time = models.DateTimeField(auto_now_add=True)
# Updated is the last time this entry was modified
updated_time = models.DateTimeField(auto_now=True)
# User who initiated the course action
created_user = models.ForeignKey(
User,
# allow NULL values in case the action is not initiated by a user (e.g., a background thread)
null=True,
# set on_delete to SET_NULL to prevent this model from being deleted in the event the user is deleted
on_delete=models.SET_NULL,
# add a '+' at the end to prevent a backward relation from the User model
related_name='created_by_user+'
)
# User who last updated the course action
updated_user = models.ForeignKey(
User,
# allow NULL values in case the action is not updated by a user (e.g., a background thread)
null=True,
# set on_delete to SET_NULL to prevent this model from being deleted in the event the user is deleted
on_delete=models.SET_NULL,
# add a '+' at the end to prevent a backward relation from the User model
related_name='updated_by_user+'
)
# Course that is being acted upon
course_key = CourseKeyField(max_length=255, db_index=True)
# Action that is being taken on the course
action = models.CharField(max_length=100, db_index=True)
# Current state of the action.
state = models.CharField(max_length=50)
# MANAGERS
objects = CourseActionStateManager()
class CourseActionUIState(CourseActionState):
"""
An abstract django model that is a sub-class of CourseActionState with additional fields related to UI.
"""
class Meta:
"""
See comment in CourseActionState on disabling "concrete inheritance".
"""
abstract = True
# FIELDS
# Whether or not the status should be displayed to users
should_display = models.BooleanField()
# Message related to the status
message = models.CharField(max_length=1000)
# Rerun courses also need these fields. All rerun course actions will have a row here as well.
class CourseRerunState(CourseActionUIState):
"""
A concrete django model for maintaining state specifically for the Action Course Reruns.
"""
class Meta:
"""
Set the (destination) course_key field to be unique for the rerun action
Although multiple reruns can be in progress simultaneously for a particular source course_key,
only a single rerun action can be in progress for the destination course_key.
"""
unique_together = ("course_key", "action")
# FIELDS
# Original course that is being rerun
source_course_key = CourseKeyField(max_length=255, db_index=True)
# MANAGERS
# Override the abstract class' manager with a Rerun-specific manager that inherits from the base class' manager.
objects = CourseRerunUIStateManager()
# pylint: disable=invalid-name, attribute-defined-outside-init
"""
Tests for basic common operations related to Course Action State managers
"""
from ddt import ddt, data
from django.test import TestCase
from collections import namedtuple
from opaque_keys.edx.locations import CourseLocator
from course_action_state.models import CourseRerunState
from course_action_state.managers import CourseActionStateItemNotFoundError
# Sequence of Action models to be tested with ddt.
COURSE_ACTION_STATES = (CourseRerunState, )
class TestCourseActionStateManagerBase(TestCase):
"""
Base class for testing Course Action State Managers.
"""
def setUp(self):
self.course_key = CourseLocator("test_org", "test_course_num", "test_run")
@ddt
class TestCourseActionStateManager(TestCourseActionStateManagerBase):
"""
Test class for testing the CourseActionStateManager.
"""
@data(*COURSE_ACTION_STATES)
def test_update_state_allow_not_found_is_false(self, action_class):
with self.assertRaises(CourseActionStateItemNotFoundError):
action_class.objects.update_state(self.course_key, "fake_state", allow_not_found=False)
@data(*COURSE_ACTION_STATES)
def test_update_state_allow_not_found(self, action_class):
action_class.objects.update_state(self.course_key, "initial_state", allow_not_found=True)
self.assertIsNotNone(
action_class.objects.find_first(course_key=self.course_key)
)
@data(*COURSE_ACTION_STATES)
def test_delete(self, action_class):
obj = action_class.objects.update_state(self.course_key, "initial_state", allow_not_found=True)
action_class.objects.delete(obj.id)
with self.assertRaises(CourseActionStateItemNotFoundError):
action_class.objects.find_first(course_key=self.course_key)
@ddt
class TestCourseActionUIStateManager(TestCourseActionStateManagerBase):
"""
Test class for testing the CourseActionUIStateManager.
"""
def init_course_action_states(self, action_class):
"""
Creates course action state entries with different states for the given action model class.
Creates both displayable (should_display=True) and non-displayable (should_display=False) entries.
"""
def create_course_states(starting_course_num, ending_course_num, state, should_display=True):
"""
Creates a list of course state tuples by creating unique course locators with course-numbers
from starting_course_num to ending_course_num.
"""
CourseState = namedtuple('CourseState', 'course_key, state, should_display')
return [
CourseState(CourseLocator("org", "course", "run" + str(num)), state, should_display)
for num in range(starting_course_num, ending_course_num)
]
NUM_COURSES_WITH_STATE1 = 3
NUM_COURSES_WITH_STATE2 = 3
NUM_COURSES_WITH_STATE3 = 3
NUM_COURSES_NON_DISPLAYABLE = 3
# courses with state1 and should_display=True
self.courses_with_state1 = create_course_states(
0,
NUM_COURSES_WITH_STATE1,
'state1'
)
# courses with state2 and should_display=True
self.courses_with_state2 = create_course_states(
NUM_COURSES_WITH_STATE1,
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2,
'state2'
)
# courses with state3 and should_display=True
self.courses_with_state3 = create_course_states(
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2,
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3,
'state3'
)
# all courses with should_display=True
self.course_actions_displayable_states = (
self.courses_with_state1 + self.courses_with_state2 + self.courses_with_state3
)
# courses with state3 and should_display=False
self.courses_with_state3_non_displayable = create_course_states(
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3,
NUM_COURSES_WITH_STATE1 + NUM_COURSES_WITH_STATE2 + NUM_COURSES_WITH_STATE3 + NUM_COURSES_NON_DISPLAYABLE,
'state3',
should_display=False,
)
# create course action states for all courses
for CourseState in (self.course_actions_displayable_states + self.courses_with_state3_non_displayable):
action_class.objects.update_state(
CourseState.course_key,
CourseState.state,
should_display=CourseState.should_display,
allow_not_found=True
)
def assertCourseActionStatesEqual(self, expected, found):
"""Asserts that the set of course keys in the expected state equal those that are found"""
self.assertSetEqual(
set(course_action_state.course_key for course_action_state in expected),
set(course_action_state.course_key for course_action_state in found))
@data(*COURSE_ACTION_STATES)
def test_find_all_for_display(self, action_class):
self.init_course_action_states(action_class)
self.assertCourseActionStatesEqual(
self.course_actions_displayable_states,
action_class.objects.find_all(should_display=True),
)
@data(*COURSE_ACTION_STATES)
def test_find_all_for_display_filter_exclude(self, action_class):
self.init_course_action_states(action_class)
for course_action_state, filter_state, exclude_state in (
(self.courses_with_state1, 'state1', None), # filter for state1
(self.courses_with_state2, 'state2', None), # filter for state2
(self.courses_with_state2 + self.courses_with_state3, None, 'state1'), # exclude state1
(self.courses_with_state1 + self.courses_with_state3, None, 'state2'), # exclude state2
(self.courses_with_state1, 'state1', 'state2'), # filter for state1, exclude state2
([], 'state1', 'state1'), # filter for state1, exclude state1
):
self.assertCourseActionStatesEqual(
course_action_state,
action_class.objects.find_all(
exclude_args=({'state': exclude_state} if exclude_state else None),
should_display=True,
**({'state': filter_state} if filter_state else {})
)
)
def test_kwargs_in_update_state(self):
destination_course_key = CourseLocator("org", "course", "run")
source_course_key = CourseLocator("source_org", "source_course", "source_run")
CourseRerunState.objects.update_state(
course_key=destination_course_key,
new_state='state1',
allow_not_found=True,
source_course_key=source_course_key,
)
found_action_state = CourseRerunState.objects.find_first(course_key=destination_course_key)
self.assertEquals(source_course_key, found_action_state.source_course_key)
"""
Tests specific to the CourseRerunState Model and Manager.
"""
from django.test import TestCase
from opaque_keys.edx.locations import CourseLocator
from course_action_state.models import CourseRerunState
from course_action_state.managers import CourseRerunUIStateManager
from student.tests.factories import UserFactory
class TestCourseRerunStateManager(TestCase):
"""
Test class for testing the CourseRerunUIStateManager.
"""
def setUp(self):
self.source_course_key = CourseLocator("source_org", "source_course_num", "source_run")
self.course_key = CourseLocator("test_org", "test_course_num", "test_run")
self.created_user = UserFactory()
self.expected_rerun_state = {
'created_user': self.created_user,
'updated_user': self.created_user,
'course_key': self.course_key,
'action': CourseRerunUIStateManager.ACTION,
'should_display': True,
'message': "",
}
def verify_rerun_state(self):
"""
Gets the rerun state object for self.course_key and verifies that the values
of its fields equal self.expected_rerun_state.
"""
found_rerun = CourseRerunState.objects.find_first(course_key=self.course_key)
found_rerun_state = {key: getattr(found_rerun, key) for key in self.expected_rerun_state}
self.assertDictEqual(found_rerun_state, self.expected_rerun_state)
return found_rerun
def dismiss_ui_and_verify(self, rerun):
"""
Updates the should_display field of the rerun state object for self.course_key
and verifies its new state.
"""
user_who_dismisses_ui = UserFactory()
CourseRerunState.objects.update_should_display(
entry_id=rerun.id,
user=user_who_dismisses_ui,
should_display=False,
)
self.expected_rerun_state.update({
'updated_user': user_who_dismisses_ui,
'should_display': False,
})
self.verify_rerun_state()
def test_rerun_initiated(self):
CourseRerunState.objects.initiated(
source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user
)
self.expected_rerun_state.update(
{'state': CourseRerunUIStateManager.State.IN_PROGRESS}
)
self.verify_rerun_state()
def test_rerun_succeeded(self):
# initiate
CourseRerunState.objects.initiated(
source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user
)
# set state to succeed
CourseRerunState.objects.succeeded(course_key=self.course_key)
self.expected_rerun_state.update({
'state': CourseRerunUIStateManager.State.SUCCEEDED,
})
rerun = self.verify_rerun_state()
# dismiss ui and verify
self.dismiss_ui_and_verify(rerun)
def test_rerun_failed(self):
# initiate
CourseRerunState.objects.initiated(
source_course_key=self.source_course_key, destination_course_key=self.course_key, user=self.created_user
)
# set state to fail
exception = Exception("failure in rerunning")
CourseRerunState.objects.failed(course_key=self.course_key, exception=exception)
self.expected_rerun_state.update({
'state': CourseRerunUIStateManager.State.FAILED,
'message': exception.message,
})
rerun = self.verify_rerun_state()
# dismiss ui and verify
self.dismiss_ui_and_verify(rerun)
......@@ -1317,6 +1317,9 @@ INSTALLED_APPS = (
# Monitoring functionality
'monitoring',
# Course action state
'course_action_state'
)
######################### MARKETING SITE ###############################
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment