Merge pull request #8338 from edx/will/credit-provider-integration
Credit provider integration Python API (Part 1 of 3)
This commit is contained in:
@@ -680,6 +680,9 @@ INSTALLED_APPS = (
|
||||
'south',
|
||||
'method_override',
|
||||
|
||||
# History tables
|
||||
'simple_history',
|
||||
|
||||
# Database-backed configuration
|
||||
'config_models',
|
||||
|
||||
|
||||
@@ -1711,6 +1711,9 @@ INSTALLED_APPS = (
|
||||
'djcelery',
|
||||
'south',
|
||||
|
||||
# History tables
|
||||
'simple_history',
|
||||
|
||||
# Database-backed configuration
|
||||
'config_models',
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,4 +66,4 @@ class Migration(SchemaMigration):
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['credit']
|
||||
complete_apps = ['credit']
|
||||
|
||||
@@ -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']
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user