From e5a62aaabaf95ddd6dc28255279beaddbd05d955 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 29 May 2015 08:13:01 -0400 Subject: [PATCH] Credit provider integration Python API * Add Python API functions for creating and updating requests for credit. * Add Django models and migrations for tracking credit requests and status. --- cms/envs/common.py | 3 + lms/envs/common.py | 3 + openedx/core/djangoapps/credit/api.py | 227 ++++++++++- openedx/core/djangoapps/credit/exceptions.py | 36 +- ...0003_add_creditrequirementstatus_reason.py | 2 +- ...ique_creditrequest_username_course_prov.py | 190 +++++++++ openedx/core/djangoapps/credit/models.py | 156 ++++++-- .../core/djangoapps/credit/tests/test_api.py | 376 ++++++++++++++---- .../djangoapps/credit/tests/test_models.py | 9 +- requirements/edx/base.txt | 1 + 10 files changed, 877 insertions(+), 126 deletions(-) create mode 100644 openedx/core/djangoapps/credit/migrations/0006_auto__add_creditrequest__add_unique_creditrequest_username_course_prov.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 0069746a73..886ab13887 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -680,6 +680,9 @@ INSTALLED_APPS = ( 'south', 'method_override', + # History tables + 'simple_history', + # Database-backed configuration 'config_models', diff --git a/lms/envs/common.py b/lms/envs/common.py index 03bfe9a4ea..74c546c69c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1710,6 +1710,9 @@ INSTALLED_APPS = ( 'djcelery', 'south', + # History tables + 'simple_history', + # Database-backed configuration 'config_models', diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index e64abc1f67..8622d4a914 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -1,12 +1,33 @@ """ Contains the APIs for course credit requirements """ +import logging +import uuid +from django.db import transaction -from .exceptions import InvalidCreditRequirements -from .models import CreditCourse, CreditRequirement -from openedx.core.djangoapps.credit.exceptions import InvalidCreditCourse +from student.models import User + +from .exceptions import ( + InvalidCreditRequirements, + InvalidCreditCourse, + UserIsNotEligible, + RequestAlreadyCompleted, + CreditRequestNotFound, + InvalidCreditStatus, +) +from .models import ( + CreditCourse, + CreditRequirement, + CreditRequirementStatus, + CreditRequest, + CreditEligibility, +) + + +log = logging.getLogger(__name__) def set_credit_requirements(course_key, requirements): - """Add requirements to given course. + """ + Add requirements to given course. Args: course_key(CourseKey): The identifier for course @@ -63,7 +84,8 @@ def set_credit_requirements(course_key, requirements): def get_credit_requirements(course_key, namespace=None): - """Get credit eligibility requirements of a given course and namespace. + """ + Get credit eligibility requirements of a given course and namespace. Args: course_key(CourseKey): The identifier for course @@ -111,8 +133,198 @@ def get_credit_requirements(course_key, namespace=None): ] +@transaction.commit_on_success +def create_credit_request(course_key, provider_id, username): + """ + Initiate a request for credit from a credit provider. + + This will return the parameters that the user's browser will need to POST + to the credit provider. It does NOT calculate the signature. + + Only users who are eligible for credit (have satisfied all credit requirements) are allowed to make requests. + + A database record will be created to track the request with a 32-character UUID. + The returned dictionary can be used by the user's browser to send a POST request to the credit provider. + + If a pending request already exists, this function should return a request description with the same UUID. + (Other parameters, such as the user's full name may be different than the original request). + + If a completed request (either accepted or rejected) already exists, this function will + raise an exception. Users are not allowed to make additional requests once a request + has been completed. + + Arguments: + course_key (CourseKey): The identifier for the course. + provider_id (str): The identifier of the credit provider. + user (User): The user initiating the request. + + Returns: dict + + Raises: + UserIsNotEligible: The user has not satisfied eligibility requirements for credit. + RequestAlreadyCompleted: The user has already submitted a request and received a response + from the credit provider. + + Example Usage: + >>> create_credit_request(course.id, "hogwarts", "ron") + { + "uuid": "557168d0f7664fe59097106c67c3f847", + "timestamp": "2015-05-04T20:57:57.987119+00:00", + "course_org": "HogwartsX", + "course_num": "Potions101", + "course_run": "1T2015", + "final_grade": 0.95, + "user_username": "ron", + "user_email": "ron@example.com", + "user_full_name": "Ron Weasley", + "user_mailing_address": "", + "user_country": "US", + } + + """ + try: + user_eligibility = CreditEligibility.objects.select_related('course', 'provider').get( + username=username, + course__course_key=course_key, + provider__provider_id=provider_id + ) + credit_course = user_eligibility.course + credit_provider = user_eligibility.provider + except CreditEligibility.DoesNotExist: + raise UserIsNotEligible + + # Initiate a new request if one has not already been created + credit_request, created = CreditRequest.objects.get_or_create( + course=credit_course, + provider=credit_provider, + username=username, + ) + + # Check whether we've already gotten a response for a request, + # If so, we're not allowed to issue any further requests. + # Skip checking the status if we know that we just created this record. + if not created and credit_request.status != "pending": + raise RequestAlreadyCompleted + + if created: + credit_request.uuid = uuid.uuid4().hex + + # Retrieve user account and profile info + user = User.objects.select_related('profile').get(username=username) + + # Retrieve the final grade from the eligibility table + try: + final_grade = CreditRequirementStatus.objects.filter( + username=username, + requirement__namespace="grade", + requirement__name="grade", + status="satisfied" + ).latest().reason["final_grade"] + except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError): + log.exception( + "Could not retrieve final grade from the credit eligibility table " + "for user %s in course %s.", + user.id, course_key + ) + raise UserIsNotEligible + + parameters = { + "uuid": credit_request.uuid, + "timestamp": credit_request.timestamp.isoformat(), + "course_org": course_key.org, + "course_num": course_key.course, + "course_run": course_key.run, + "final_grade": final_grade, + "user_username": user.username, + "user_email": user.email, + "user_full_name": user.profile.name, + "user_mailing_address": ( + user.profile.mailing_address + if user.profile.mailing_address is not None + else "" + ), + "user_country": ( + user.profile.country.code + if user.profile.country.code is not None + else "" + ), + } + + credit_request.parameters = parameters + credit_request.save() + + return parameters + + +def update_credit_request_status(request_uuid, status): + """ + Update the status of a credit request. + + Approve or reject a request for a student to receive credit in a course + from a particular credit provider. + + This function does NOT check that the status update is authorized. + The caller needs to handle authentication and authorization (checking the signature + of the message received from the credit provider) + + The function is idempotent; if the request has already been updated to the status, + the function does nothing. + + Arguments: + request_uuid (str): The unique identifier for the credit request. + status (str): Either "approved" or "rejected" + + Returns: None + + Raises: + CreditRequestNotFound: The request does not exist. + InvalidCreditStatus: The status is not either "approved" or "rejected". + + """ + if status not in ["approved", "rejected"]: + raise InvalidCreditStatus + + try: + request = CreditRequest.objects.get(uuid=request_uuid) + request.status = status + request.save() + except CreditRequest.DoesNotExist: + raise CreditRequestNotFound + + +def get_credit_requests_for_user(username): + """ + Retrieve the status of a credit request. + + Returns either "pending", "accepted", or "rejected" + + Arguments: + username (unicode): The username of the user who initiated the requests. + + Returns: list + + Example Usage: + >>> get_credit_request_status_for_user("bob") + [ + { + "uuid": "557168d0f7664fe59097106c67c3f847", + "timestamp": "2015-05-04T20:57:57.987119+00:00", + "course_key": "course-v1:HogwartsX+Potions101+1T2015", + "provider": { + "id": "HogwartsX", + "display_name": "Hogwarts School of Witchcraft and Wizardry", + }, + "status": "pending" # or "approved" or "rejected" + } + ] + + """ + return CreditRequest.credit_requests_for_user(username) + + def _get_requirements_to_disable(old_requirements, new_requirements): - """Get the ids of 'CreditRequirement' entries to be disabled that are + """ + Get the ids of 'CreditRequirement' entries to be disabled that are deleted from the courseware. Args: @@ -136,7 +348,8 @@ def _get_requirements_to_disable(old_requirements, new_requirements): def _validate_requirements(requirements): - """Validate the requirements. + """ + Validate the requirements. Args: requirements(list): List of requirements diff --git a/openedx/core/djangoapps/credit/exceptions.py b/openedx/core/djangoapps/credit/exceptions.py index 2ada6c3f1f..e2ea783a80 100644 --- a/openedx/core/djangoapps/credit/exceptions.py +++ b/openedx/core/djangoapps/credit/exceptions.py @@ -1,17 +1,43 @@ -""" -This module contains the exceptions raised in credit course requirements. -""" +"""Exceptions raised by the credit API. """ class InvalidCreditRequirements(Exception): """ - The exception occurs when the requirement dictionary has invalid format. + The requirement dictionary provided has invalid format. """ pass class InvalidCreditCourse(Exception): """ - The exception occurs when the the course is not marked as a Credit Course. + The course is not configured for credit. + """ + pass + + +class UserIsNotEligible(Exception): + """ + The user has not satisfied eligibility requirements for credit. + """ + pass + + +class RequestAlreadyCompleted(Exception): + """ + The user has already submitted a request and received a response from the credit provider. + """ + pass + + +class CreditRequestNotFound(Exception): + """ + The request does not exist. + """ + pass + + +class InvalidCreditStatus(Exception): + """ + The status is not either "approved" or "rejected". """ pass diff --git a/openedx/core/djangoapps/credit/migrations/0003_add_creditrequirementstatus_reason.py b/openedx/core/djangoapps/credit/migrations/0003_add_creditrequirementstatus_reason.py index b2f5416f4a..6a3c03ec65 100644 --- a/openedx/core/djangoapps/credit/migrations/0003_add_creditrequirementstatus_reason.py +++ b/openedx/core/djangoapps/credit/migrations/0003_add_creditrequirementstatus_reason.py @@ -66,4 +66,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['credit'] \ No newline at end of file + complete_apps = ['credit'] diff --git a/openedx/core/djangoapps/credit/migrations/0006_auto__add_creditrequest__add_unique_creditrequest_username_course_prov.py b/openedx/core/djangoapps/credit/migrations/0006_auto__add_creditrequest__add_unique_creditrequest_username_course_prov.py new file mode 100644 index 0000000000..723aa9fedc --- /dev/null +++ b/openedx/core/djangoapps/credit/migrations/0006_auto__add_creditrequest__add_unique_creditrequest_username_course_prov.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as 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 'CreditRequest' + db.create_table('credit_creditrequest', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)), + ('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)), + ('uuid', self.gf('django.db.models.fields.CharField')(unique=True, max_length=32, db_index=True)), + ('username', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('course', self.gf('django.db.models.fields.related.ForeignKey')(related_name='credit_requests', to=orm['credit.CreditCourse'])), + ('provider', self.gf('django.db.models.fields.related.ForeignKey')(related_name='credit_requests', to=orm['credit.CreditProvider'])), + ('timestamp', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('parameters', self.gf('jsonfield.fields.JSONField')()), + ('status', self.gf('django.db.models.fields.CharField')(default='pending', max_length=255)), + )) + db.send_create_signal('credit', ['CreditRequest']) + + # Adding unique constraint on 'CreditRequest', fields ['username', 'course', 'provider'] + db.create_unique('credit_creditrequest', ['username', 'course_id', 'provider_id']) + + # Adding model 'HistoricalCreditRequest' + db.create_table('credit_historicalcreditrequest', ( + ('id', self.gf('django.db.models.fields.IntegerField')(db_index=True, blank=True)), + ('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)), + ('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)), + ('uuid', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('username', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('timestamp', self.gf('django.db.models.fields.DateTimeField')(blank=True)), + ('parameters', self.gf('jsonfield.fields.JSONField')()), + ('status', self.gf('django.db.models.fields.CharField')(default='pending', max_length=255)), + ('course', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name=u'+', null=True, on_delete=models.DO_NOTHING, to=orm['credit.CreditCourse'])), + ('provider', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name=u'+', null=True, on_delete=models.DO_NOTHING, to=orm['credit.CreditProvider'])), + (u'history_id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + (u'history_date', self.gf('django.db.models.fields.DateTimeField')()), + (u'history_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name=u'+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])), + (u'history_type', self.gf('django.db.models.fields.CharField')(max_length=1)), + )) + db.send_create_signal('credit', ['HistoricalCreditRequest']) + + # Adding M2M table for field providers on 'CreditCourse' + m2m_table_name = db.shorten_name('credit_creditcourse_providers') + db.create_table(m2m_table_name, ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('creditcourse', models.ForeignKey(orm['credit.creditcourse'], null=False)), + ('creditprovider', models.ForeignKey(orm['credit.creditprovider'], null=False)) + )) + db.create_unique(m2m_table_name, ['creditcourse_id', 'creditprovider_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'CreditRequest', fields ['username', 'course', 'provider'] + db.delete_unique('credit_creditrequest', ['username', 'course_id', 'provider_id']) + + # Deleting model 'CreditRequest' + db.delete_table('credit_creditrequest') + + # Deleting model 'HistoricalCreditRequest' + db.delete_table('credit_historicalcreditrequest') + + # Removing M2M table for field providers on 'CreditCourse' + db.delete_table(db.shorten_name('credit_creditcourse_providers')) + + + 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'}) + }, + 'credit.creditcourse': { + 'Meta': {'object_name': 'CreditCourse'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'providers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['credit.CreditProvider']", 'symmetrical': 'False'}) + }, + 'credit.crediteligibility': { + 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditProvider']"}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.creditprovider': { + 'Meta': {'object_name': 'CreditProvider'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'eligibility_duration': ('django.db.models.fields.PositiveIntegerField', [], {'default': '31556970'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'unique': 'True', 'max_length': '255'}) + }, + 'credit.creditrequest': { + 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}) + }, + 'credit.creditrequirement': { + 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'criteria': ('jsonfield.fields.JSONField', [], {}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'credit.creditrequirementstatus': { + 'Meta': {'object_name': 'CreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequest': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}) + } + } + + complete_apps = ['credit'] diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index 640772ffa6..b0f65bb58d 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -9,6 +9,8 @@ successful completion of a course on EdX import logging from django.db import models +from simple_history.models import HistoricalRecords + from jsonfield.fields import JSONField from model_utils.models import TimeStampedModel @@ -19,6 +21,29 @@ from django.utils.translation import ugettext_lazy log = logging.getLogger(__name__) +class CreditProvider(TimeStampedModel): + """This model represents an institution that can grant credit for a course. + + Each provider is identified by unique ID (e.g., 'ASU'). CreditProvider also + includes a `url` where the student will be sent when he/she will try to + get credit for course. Eligibility duration will be use to set duration + for which credit eligible message appears on dashboard. + """ + + provider_id = models.CharField(max_length=255, db_index=True, unique=True) + display_name = models.CharField(max_length=255) + provider_url = models.URLField(max_length=255, unique=True, default="") + + # Default is one year + DEFAULT_ELIGIBILITY_DURATION = 31556970 + + eligibility_duration = models.PositiveIntegerField( + help_text=ugettext_lazy(u"Number of seconds to show eligibility message"), + default=DEFAULT_ELIGIBILITY_DURATION + ) + active = models.BooleanField(default=True) + + class CreditCourse(models.Model): """ Model for tracking a credit course. @@ -26,6 +51,7 @@ class CreditCourse(models.Model): course_key = CourseKeyField(max_length=255, db_index=True, unique=True) enabled = models.BooleanField(default=False) + providers = models.ManyToManyField(CreditProvider) @classmethod def is_credit_course(cls, course_key): @@ -55,26 +81,9 @@ class CreditCourse(models.Model): return cls.objects.get(course_key=course_key, enabled=True) -class CreditProvider(TimeStampedModel): - """This model represents an institution that can grant credit for a course. - - Each provider is identified by unique ID (e.g., 'ASU'). CreditProvider also - includes a `url` where the student will be sent when he/she will try to - get credit for course. Eligibility duration will be use to set duration - for which credit eligible message appears on dashboard. - """ - - provider_id = models.CharField(max_length=255, db_index=True, unique=True) - display_name = models.CharField(max_length=255) - provider_url = models.URLField(max_length=255, unique=True) - eligibility_duration = models.PositiveIntegerField( - help_text=ugettext_lazy(u"Number of seconds to show eligibility message") - ) - active = models.BooleanField(default=True) - - class CreditRequirement(TimeStampedModel): - """This model represents a credit requirement. + """ + This model represents a credit requirement. Each requirement is uniquely identified by its 'namespace' and 'name' fields. @@ -89,7 +98,7 @@ class CreditRequirement(TimeStampedModel): course = models.ForeignKey(CreditCourse, related_name="credit_requirements") namespace = models.CharField(max_length=255) name = models.CharField(max_length=255) - display_name = models.CharField(max_length=255) + display_name = models.CharField(max_length=255, default="") criteria = JSONField() active = models.BooleanField(default=True) @@ -101,7 +110,8 @@ class CreditRequirement(TimeStampedModel): @classmethod def add_or_update_course_requirement(cls, credit_course, requirement): - """Add requirement to a given course. + """ + Add requirement to a given course. Args: credit_course(CreditCourse): The identifier for credit course @@ -127,7 +137,8 @@ class CreditRequirement(TimeStampedModel): @classmethod def get_course_requirements(cls, course_key, namespace=None): - """Get credit requirements of a given course. + """ + Get credit requirements of a given course. Args: course_key(CourseKey): The identifier for a course @@ -143,7 +154,8 @@ class CreditRequirement(TimeStampedModel): @classmethod def disable_credit_requirements(cls, requirement_ids): - """Mark the given requirements inactive. + """ + Mark the given requirements inactive. Args: requirement_ids(list): List of ids @@ -155,7 +167,8 @@ class CreditRequirement(TimeStampedModel): class CreditRequirementStatus(TimeStampedModel): - """This model represents the status of each requirement. + """ + This model represents the status of each requirement. For a particular credit requirement, a user can either: 1) Have satisfied the requirement (example: approved in-course reverification) @@ -176,7 +189,7 @@ class CreditRequirementStatus(TimeStampedModel): username = models.CharField(max_length=255, db_index=True) requirement = models.ForeignKey(CreditRequirement, related_name="statuses") - status = models.CharField(choices=REQUIREMENT_STATUS_CHOICES, max_length=32) + status = models.CharField(max_length=32, choices=REQUIREMENT_STATUS_CHOICES) # Include additional information about why the user satisfied or failed # the requirement. This is specific to the type of requirement. @@ -185,9 +198,13 @@ class CreditRequirementStatus(TimeStampedModel): # the grade to users later and to send the information to credit providers. reason = JSONField(default={}) + class Meta(object): # pylint: disable=missing-docstring + get_latest_by = "created" + class CreditEligibility(TimeStampedModel): - """A record of a user's eligibility for credit from a specific credit + """ + A record of a user's eligibility for credit from a specific credit provider for a specific course. """ @@ -195,8 +212,87 @@ class CreditEligibility(TimeStampedModel): course = models.ForeignKey(CreditCourse, related_name="eligibilities") provider = models.ForeignKey(CreditProvider, related_name="eligibilities") - class Meta(object): - """ - Model metadata. - """ + class Meta(object): # pylint: disable=missing-docstring unique_together = ('username', 'course') + + +class CreditRequest(TimeStampedModel): + """ + A request for credit from a particular credit provider. + + When a user initiates a request for credit, a CreditRequest record will be created. + Each CreditRequest is assigned a unique identifier so we can find it when the request + is approved by the provider. The CreditRequest record stores the parameters to be sent + at the time the request is made. If the user re-issues the request + (perhaps because the user did not finish filling in forms on the credit provider's site), + the request record will be updated, but the UUID will remain the same. + """ + + uuid = models.CharField(max_length=32, unique=True, db_index=True) + username = models.CharField(max_length=255, db_index=True) + course = models.ForeignKey(CreditCourse, related_name="credit_requests") + provider = models.ForeignKey(CreditProvider, related_name="credit_requests") + timestamp = models.DateTimeField(auto_now_add=True) + parameters = JSONField() + + REQUEST_STATUS_PENDING = "pending" + REQUEST_STATUS_APPROVED = "approved" + REQUEST_STATUS_REJECTED = "rejected" + + REQUEST_STATUS_CHOICES = ( + (REQUEST_STATUS_PENDING, "Pending"), + (REQUEST_STATUS_APPROVED, "Approved"), + (REQUEST_STATUS_REJECTED, "Rejected"), + ) + status = models.CharField( + max_length=255, + choices=REQUEST_STATUS_CHOICES, + default=REQUEST_STATUS_PENDING + ) + + history = HistoricalRecords() + + @classmethod + def credit_requests_for_user(cls, username): + """ + Retrieve all credit requests for a user. + + Arguments: + username (unicode): The username of the user. + + Returns: list + + Example Usage: + >>> CreditRequest.credit_requests_for_user("bob") + [ + { + "uuid": "557168d0f7664fe59097106c67c3f847", + "timestamp": "2015-05-04T20:57:57.987119+00:00", + "course_key": "course-v1:HogwartsX+Potions101+1T2015", + "provider": { + "id": "HogwartsX", + "display_name": "Hogwarts School of Witchcraft and Wizardry", + }, + "status": "pending" # or "approved" or "rejected" + } + ] + + """ + return [ + { + "uuid": request.uuid, + "timestamp": request.modified, + "course_key": request.course.course_key, + "provider": { + "id": request.provider.provider_id, + "display_name": request.provider.display_name + }, + "status": request.status + } + for request in cls.objects.select_related('course', 'provider').filter(username=username) + ] + + class Meta(object): # pylint: disable=missing-docstring + # Enforce the constraint that each user can have exactly one outstanding + # request to a given provider. Multiple requests use the same UUID. + unique_together = ('username', 'course', 'provider') diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 0dcd52bdb3..194e9918ff 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -1,29 +1,64 @@ """ -Tests for credit course api. +Tests for the API functions in the credit app. """ +import datetime import ddt +import pytz +import dateutil.parser as date_parser +from django.test import TestCase +from django.db import connection, transaction from opaque_keys.edx.keys import CourseKey -from openedx.core.djangoapps.credit.api import ( - get_credit_requirements, set_credit_requirements, _get_requirements_to_disable +from student.tests.factories import UserFactory +from openedx.core.djangoapps.credit import api +from openedx.core.djangoapps.credit.exceptions import ( + InvalidCreditRequirements, + InvalidCreditCourse, + RequestAlreadyCompleted, + UserIsNotEligible, + InvalidCreditStatus, + CreditRequestNotFound, ) -from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse -from openedx.core.djangoapps.credit.models import CreditCourse, CreditRequirement -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from openedx.core.djangoapps.credit.models import ( + CreditCourse, + CreditProvider, + CreditRequirement, + CreditRequirementStatus, + CreditEligibility, +) + + +class CreditApiTestBase(TestCase): + """ + Base class for test cases of the credit API. + """ + + PROVIDER_ID = "hogwarts" + PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry" + + def setUp(self, **kwargs): + super(CreditApiTestBase, self).setUp() + self.course_key = CourseKey.from_string("edX/DemoX/Demo_Course") + + def add_credit_course(self, enabled=True): + """Mark the course as a credit """ + credit_course = CreditCourse.objects.create(course_key=self.course_key, enabled=enabled) + + # Associate a credit provider with the course. + credit_provider = CreditProvider.objects.create(provider_id=self.PROVIDER_ID, display_name=self.PROVIDER_NAME) + credit_course.providers.add(credit_provider) + + return credit_course @ddt.ddt -class ApiTestCases(ModuleStoreTestCase): +class CreditRequirementApiTests(CreditApiTestBase): """ - Tests for credit course api. + Test Python API for credit requirements and eligibility. """ - def setUp(self, **kwargs): - super(ApiTestCases, self).setUp() - self.course_key = CourseKey.from_string("edX/DemoX/Demo_Course") - @ddt.data( [ { @@ -52,12 +87,11 @@ class ApiTestCases(ModuleStoreTestCase): def test_set_credit_requirements_invalid_requirements(self, requirements): self.add_credit_course() with self.assertRaises(InvalidCreditRequirements): - set_credit_requirements(self.course_key, requirements) + api.set_credit_requirements(self.course_key, requirements) def test_set_credit_requirements_invalid_course(self): - """Test that 'InvalidCreditCourse' exception is raise if we try to - set credit requirements for a non credit course. - """ + # Test that 'InvalidCreditCourse' exception is raise if we try to + # set credit requirements for a non credit course. requirements = [ { "namespace": "grade", @@ -67,16 +101,14 @@ class ApiTestCases(ModuleStoreTestCase): } ] with self.assertRaises(InvalidCreditCourse): - set_credit_requirements(self.course_key, requirements) + api.set_credit_requirements(self.course_key, requirements) self.add_credit_course(enabled=False) with self.assertRaises(InvalidCreditCourse): - set_credit_requirements(self.course_key, requirements) + api.set_credit_requirements(self.course_key, requirements) def test_set_get_credit_requirements(self): - """Test that if same requirement is added multiple times - then it is added only one time and update for next all iterations. - """ + # Test that if same requirement is added multiple times self.add_credit_course() requirements = [ { @@ -96,12 +128,38 @@ class ApiTestCases(ModuleStoreTestCase): } } ] - set_credit_requirements(self.course_key, requirements) - self.assertEqual(len(get_credit_requirements(self.course_key)), 1) + api.set_credit_requirements(self.course_key, requirements) + self.assertEqual(len(api.get_credit_requirements(self.course_key)), 1) - # now verify that the saved requirement has values of last requirement - # from all same requirements - self.assertEqual(get_credit_requirements(self.course_key)[0], requirements[1]) + def test_disable_existing_requirement(self): + self.add_credit_course() + + # Set initial requirements + requirements = [ + { + "namespace": "reverification", + "name": "midterm", + "display_name": "Midterm", + "criteria": {} + }, + { + "namespace": "grade", + "name": "grade", + "display_name": "Grade", + "criteria": { + "min_grade": 0.8 + } + } + ] + api.set_credit_requirements(self.course_key, requirements) + + # Update the requirements, removing an existing requirement + api.set_credit_requirements(self.course_key, requirements[1:]) + + # Expect that now only the grade requirement is returned + visible_reqs = api.get_credit_requirements(self.course_key) + self.assertEqual(len(visible_reqs), 1) + self.assertEqual(visible_reqs[0]["namespace"], "grade") def test_disable_credit_requirements(self): self.add_credit_course() @@ -115,8 +173,8 @@ class ApiTestCases(ModuleStoreTestCase): } } ] - set_credit_requirements(self.course_key, requirements) - self.assertEqual(len(get_credit_requirements(self.course_key)), 1) + api.set_credit_requirements(self.course_key, requirements) + self.assertEqual(len(api.get_credit_requirements(self.course_key)), 1) requirements = [ { @@ -126,65 +184,225 @@ class ApiTestCases(ModuleStoreTestCase): "criteria": {} } ] - set_credit_requirements(self.course_key, requirements) - self.assertEqual(len(get_credit_requirements(self.course_key)), 1) + api.set_credit_requirements(self.course_key, requirements) + self.assertEqual(len(api.get_credit_requirements(self.course_key)), 1) grade_req = CreditRequirement.objects.filter(namespace="grade", name="grade") self.assertEqual(len(grade_req), 1) self.assertEqual(grade_req[0].active, False) - def test_requirements_to_disable(self): - self.add_credit_course() - requirements = [ - { - "namespace": "grade", - "name": "grade", - "display_name": "Grade", - "criteria": { - "min_grade": 0.8 - } - } - ] - set_credit_requirements(self.course_key, requirements) - old_requirements = CreditRequirement.get_course_requirements(self.course_key) - self.assertEqual(len(old_requirements), 1) +@ddt.ddt +class CreditProviderIntegrationApiTests(CreditApiTestBase): + """ + Test Python API for credit provider integration. + """ - requirements = [ - { - "namespace": "reverification", - "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", - "display_name": "Assessment 1", - "criteria": {} - } - ] - requirements_to_disabled = _get_requirements_to_disable(old_requirements, requirements) - self.assertEqual(len(requirements_to_disabled), 1) - self.assertEqual(requirements_to_disabled[0], old_requirements[0].id) + USER_INFO = { + "username": "bob", + "email": "bob@example.com", + "full_name": "Bob", + "mailing_address": "123 Fake Street, Cambridge MA", + "country": "US", + } - requirements = [ - { - "namespace": "grade", - "name": "grade", - "display_name": "Grade", - "criteria": { - "min_grade": 0.8 - } - }, - { - "namespace": "reverification", - "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", - "display_name": "Assessment 1", - "criteria": {} - } - ] - requirements_to_disabled = _get_requirements_to_disable(old_requirements, requirements) - self.assertEqual(len(requirements_to_disabled), 0) + FINAL_GRADE = 0.95 - def add_credit_course(self, enabled=True): + def setUp(self): + super(CreditProviderIntegrationApiTests, self).setUp() + self.user = UserFactory( + username=self.USER_INFO['username'], + email=self.USER_INFO['email'], + ) + + self.user.profile.name = self.USER_INFO['full_name'] + self.user.profile.mailing_address = self.USER_INFO['mailing_address'] + self.user.profile.country = self.USER_INFO['country'] + self.user.profile.save() + + # By default, configure the database so that there is a single + # credit requirement that the user has satisfied (minimum grade) + self._configure_credit() + + def test_credit_request(self): + # Initiate a credit request + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + # Validate the UUID + self.assertIn('uuid', request) + self.assertEqual(len(request['uuid']), 32) + + # Validate the timestamp + self.assertIn('timestamp', request) + parsed_date = date_parser.parse(request['timestamp']) + self.assertTrue(parsed_date < datetime.datetime.now(pytz.UTC)) + + # Validate course information + self.assertIn('course_org', request) + self.assertEqual(request['course_org'], self.course_key.org) + self.assertIn('course_num', request) + self.assertEqual(request['course_num'], self.course_key.course) + self.assertIn('course_run', request) + self.assertEqual(request['course_run'], self.course_key.run) + self.assertIn('final_grade', request) + self.assertEqual(request['final_grade'], self.FINAL_GRADE) + + # Validate user information + for key in self.USER_INFO.keys(): + request_key = 'user_{key}'.format(key=key) + self.assertIn(request_key, request) + self.assertEqual(request[request_key], self.USER_INFO[key]) + + @ddt.data("approved", "rejected") + def test_credit_request_status(self, status): + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + # Initial status should be "pending" + self._assert_credit_status("pending") + + # Update the status + api.update_credit_request_status(request['uuid'], status) + self._assert_credit_status(status) + + def test_query_counts(self): + # Yes, this is a lot of queries, but this API call is also doing a lot of work :) + # - 1 query: Check the user's eligibility and retrieve the credit course and provider. + # - 2 queries: Get-or-create the credit request. + # - 1 query: Retrieve user account and profile information from the user API. + # - 1 query: Look up the user's final grade from the credit requirements table. + # - 2 queries: Update the request. + # - 2 queries: Update the history table for the request. + with self.assertNumQueries(9): + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + # - 3 queries: Retrieve and update the request + # - 1 query: Update the history table for the request. + with self.assertNumQueries(4): + api.update_credit_request_status(request['uuid'], "approved") + + with self.assertNumQueries(1): + api.get_credit_requests_for_user(self.USER_INFO['username']) + + def test_reuse_credit_request(self): + # Create the first request + first_request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + # Update the user's profile information, then attempt a second request + self.user.profile.name = "Bobby" + self.user.profile.save() + second_request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + # Request UUID should be the same + self.assertEqual(first_request['uuid'], second_request['uuid']) + + # Request should use the updated information + self.assertEqual(second_request['user_full_name'], "Bobby") + + @ddt.data("approved", "rejected") + def test_cannot_make_credit_request_after_response(self, status): + # Create the first request + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + # Provider updates the status + api.update_credit_request_status(request['uuid'], status) + + # Attempting a second request raises an exception + with self.assertRaises(RequestAlreadyCompleted): + api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + @ddt.data("pending", "failed") + def test_user_is_not_eligible(self, status): + # Simulate a user who is not eligible for credit + CreditEligibility.objects.all().delete() + status = CreditRequirementStatus.objects.get(username=self.USER_INFO['username']) + status.status = status + status.reason = {} + status.save() + + with self.assertRaises(UserIsNotEligible): + api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + def test_create_request_null_mailing_address(self): + # User did not specify a mailing address + self.user.profile.mailing_address = None + self.user.profile.save() + + # Request should include an empty mailing address field + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + self.assertEqual(request["user_mailing_address"], "") + + def test_create_request_null_country(self): + # Simulate users who registered accounts before the country field was introduced. + # We need to manipulate the database directly because the country Django field + # coerces None values to empty strings. + query = "UPDATE auth_userprofile SET country = NULL WHERE id = %s" + connection.cursor().execute(query, [str(self.user.profile.id)]) + transaction.commit_unless_managed() + + # Request should include an empty country field + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + self.assertEqual(request["user_country"], "") + + def test_user_has_no_final_grade(self): + # Simulate an error condition that should never happen: + # a user is eligible for credit, but doesn't have a final + # grade recorded in the eligibility requirement. + grade_status = CreditRequirementStatus.objects.get( + username=self.USER_INFO['username'], + requirement__namespace="grade", + requirement__name="grade" + ) + grade_status.reason = {} + grade_status.save() + + with self.assertRaises(UserIsNotEligible): + api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + def test_update_invalid_credit_status(self): + # The request status must be either "approved" or "rejected" + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + with self.assertRaises(InvalidCreditStatus): + api.update_credit_request_status(request['uuid'], "invalid") + + def test_update_credit_request_not_found(self): + # The request UUID must exist + with self.assertRaises(CreditRequestNotFound): + api.update_credit_request_status("invalid_uuid", "approved") + + def test_get_credit_requests_no_requests(self): + requests = api.get_credit_requests_for_user(self.USER_INFO['username']) + self.assertEqual(requests, []) + + def _configure_credit(self): """ - Mark the course as a credit. + Configure a credit course and its requirements. + + By default, add a single requirement (minimum grade) + that the user has satisfied. + """ - credit_course = CreditCourse(course_key=self.course_key, enabled=enabled) - credit_course.save() - return credit_course + credit_course = self.add_credit_course() + requirement = CreditRequirement.objects.create( + course=credit_course, + namespace="grade", + name="grade", + active=True + ) + status = CreditRequirementStatus.objects.create( + username=self.USER_INFO['username'], + requirement=requirement, + ) + status.status = "satisfied" + status.reason = {"final_grade": self.FINAL_GRADE} + status.save() + + CreditEligibility.objects.create( + username=self.USER_INFO['username'], + course=CreditCourse.objects.get(course_key=self.course_key), + provider=CreditProvider.objects.get(provider_id=self.PROVIDER_ID) + ) + + def _assert_credit_status(self, expected_status): + """Check the user's credit status. """ + statuses = api.get_credit_requests_for_user(self.USER_INFO['username']) + self.assertEqual(statuses[0]["status"], expected_status) diff --git a/openedx/core/djangoapps/credit/tests/test_models.py b/openedx/core/djangoapps/credit/tests/test_models.py index 9d6d4ef9d4..5bdb734bc4 100644 --- a/openedx/core/djangoapps/credit/tests/test_models.py +++ b/openedx/core/djangoapps/credit/tests/test_models.py @@ -1,23 +1,24 @@ +# -*- coding: utf-8 -*- """ Tests for credit course models. """ import ddt +from django.test import TestCase from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.credit.models import CreditCourse, CreditRequirement -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @ddt.ddt -class ModelTestCases(ModuleStoreTestCase): +class CreditEligibilityModelTests(TestCase): """ - Tests for credit course models. + Tests for credit models used to track credit eligibility. """ def setUp(self, **kwargs): - super(ModelTestCases, self).setUp() + super(CreditEligibilityModelTests, self).setUp() self.course_key = CourseKey.from_string("edX/DemoX/Demo_Course") @ddt.data(False, True) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 56fb22b505..37a5ccd196 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -30,6 +30,7 @@ django-openid-auth==0.4 django-robots==0.9.1 django-sekizai==0.6.1 django-ses==0.4.1 +django-simple-history==1.6.1 django-storages==1.1.5 django-threaded-multihost==1.4-1 django-method-override==0.1.0