Commit 9d60b0e0 by Will Daly

Merge pull request #8444 from edx/will/credit-provider-part-two

Credit provider integration Python API (Part 2 of 3)
parents a61b9106 8988aa1c
......@@ -990,3 +990,8 @@ CREDIT_TASK_DEFAULT_RETRY_DELAY = 30
# Maximum number of retries per task for errors that are not related
# to throttling.
CREDIT_TASK_MAX_RETRIES = 5
# Maximum age in seconds of timestamps we will accept
# when a credit provider notifies us that a student has been approved
# or denied for credit.
CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60
......@@ -637,3 +637,7 @@ else:
EDXNOTES_PUBLIC_API = ENV_TOKENS.get('EDXNOTES_PUBLIC_API', EDXNOTES_PUBLIC_API)
EDXNOTES_INTERNAL_API = ENV_TOKENS.get('EDXNOTES_INTERNAL_API', EDXNOTES_INTERNAL_API)
##### Credit Provider Integration #####
CREDIT_PROVIDER_SECRET_KEYS = AUTH_TOKENS.get("CREDIT_PROVIDER_SECRET_KEYS", {})
......@@ -403,6 +403,8 @@ FEATURES = {
# Enable OpenBadge support. See the BADGR_* settings later in this file.
'ENABLE_OPENBADGES': False,
# Credit course API
'ENABLE_CREDIT_API': False,
}
# Ignore static asset files on import which match this pattern
......@@ -2456,7 +2458,7 @@ PREVIEW_DOMAIN = 'preview'
# If set to None, all courses will be listed on the homepage
HOMEPAGE_COURSE_MAX = None
################################ Settings for Credit Course Requirements ################################
################################ Settings for Credit Courses ################################
# Initial delay used for retrying tasks.
# Additional retries use longer delays.
# Value is in seconds.
......@@ -2465,3 +2467,15 @@ CREDIT_TASK_DEFAULT_RETRY_DELAY = 30
# Maximum number of retries per task for errors that are not related
# to throttling.
CREDIT_TASK_MAX_RETRIES = 5
# Secret keys shared with credit providers.
# Used to digitally sign credit requests (us --> provider)
# and validate responses (provider --> us).
# Each key in the dictionary is a credit provider ID, and
# the value is the 32-character key.
CREDIT_PROVIDER_SECRET_KEYS = {}
# Maximum age in seconds of timestamps we will accept
# when a credit provider notifies us that a student has been approved
# or denied for credit.
CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60
......@@ -93,6 +93,7 @@ urlpatterns = (
# Video Abstraction Layer used to allow video teams to manage video assets
# independently of courseware. https://github.com/edx/edx-val
url(r'^api/val/v0/', include('edxval.urls')),
)
if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]:
......@@ -113,6 +114,12 @@ else:
url(r'^accounts/login$', 'student.views.accounts_login', name="accounts_login"),
)
if settings.FEATURES.get("ENABLE_CREDIT_API"):
# Credit API end-points
urlpatterns += (
url(r'^api/credit/', include('openedx.core.djangoapps.credit.urls', app_name="credit", namespace='credit')),
)
if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
urlpatterns += (
url(r'^api/mobile/v0.5/', include('mobile_api.urls')),
......
""" Contains the APIs for course credit requirements """
import logging
import uuid
from django.db import transaction
from student.models import User
......@@ -9,6 +10,7 @@ from .exceptions import (
InvalidCreditRequirements,
InvalidCreditCourse,
UserIsNotEligible,
CreditProviderNotConfigured,
RequestAlreadyCompleted,
CreditRequestNotFound,
InvalidCreditStatus,
......@@ -20,6 +22,7 @@ from .models import (
CreditRequest,
CreditEligibility,
)
from .signature import signature, get_shared_secret_key
log = logging.getLogger(__name__)
......@@ -143,6 +146,16 @@ def create_credit_request(course_key, provider_id, username):
Only users who are eligible for credit (have satisfied all credit requirements) are allowed to make requests.
A provider can be configured either with *integration enabled* or not.
If automatic integration is disabled, this method will simply return
a URL to the credit provider and method set to "GET", so the student can
visit the URL and request credit directly. No database record will be created
to track these requests.
If automatic integration *is* enabled, then this will also return the parameters
that the user's browser will need to POST to the credit provider.
These parameters will be digitally signed using a secret key shared with the credit provider.
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.
......@@ -162,13 +175,17 @@ def create_credit_request(course_key, provider_id, username):
Raises:
UserIsNotEligible: The user has not satisfied eligibility requirements for credit.
CreditProviderNotConfigured: The credit provider has not been configured for this course.
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",
"url": "https://credit.example.com/request",
"method": "POST",
"parameters": {
"request_uuid": "557168d0f7664fe59097106c67c3f847",
"timestamp": "2015-05-04T20:57:57.987119+00:00",
"course_org": "HogwartsX",
"course_num": "Potions101",
......@@ -179,6 +196,8 @@ def create_credit_request(course_key, provider_id, username):
"user_full_name": "Ron Weasley",
"user_mailing_address": "",
"user_country": "US",
"signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
}
}
"""
......@@ -191,8 +210,33 @@ def create_credit_request(course_key, provider_id, username):
credit_course = user_eligibility.course
credit_provider = user_eligibility.provider
except CreditEligibility.DoesNotExist:
log.warning(u'User tried to initiate a request for credit, but the user is not eligible for credit')
raise UserIsNotEligible
# Check if we've enabled automatic integration with the credit
# provider. If not, we'll show the user a link to a URL
# where the user can request credit directly from the provider.
# Note that we do NOT track these requests in our database,
# since the state would always be "pending" (we never hear back).
if not credit_provider.enable_integration:
return {
"url": credit_provider.provider_url,
"method": "GET",
"parameters": {}
}
else:
# If automatic credit integration is enabled, then try
# to retrieve the shared signature *before* creating the request.
# That way, if there's a misconfiguration, we won't have requests
# in our system that we know weren't sent to the provider.
shared_secret_key = get_shared_secret_key(credit_provider.provider_id)
if shared_secret_key is None:
msg = u'Credit provider with ID "{provider_id}" does not have a secret key configured.'.format(
provider_id=credit_provider.provider_id
)
log.error(msg)
raise CreditProviderNotConfigured(msg)
# Initiate a new request if one has not already been created
credit_request, created = CreditRequest.objects.get_or_create(
course=credit_course,
......@@ -204,6 +248,12 @@ def create_credit_request(course_key, provider_id, username):
# 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":
log.warning(
(
u'Cannot initiate credit request because the request with UUID "%s" '
u'exists with status "%s"'
), credit_request.uuid, credit_request.status
)
raise RequestAlreadyCompleted
if created:
......@@ -229,7 +279,7 @@ def create_credit_request(course_key, provider_id, username):
raise UserIsNotEligible
parameters = {
"uuid": credit_request.uuid,
"request_uuid": credit_request.uuid,
"timestamp": credit_request.timestamp.isoformat(),
"course_org": course_key.org,
"course_num": course_key.course,
......@@ -253,10 +303,25 @@ def create_credit_request(course_key, provider_id, username):
credit_request.parameters = parameters
credit_request.save()
return parameters
if created:
log.info(u'Created new request for credit with UUID "%s"', credit_request.uuid)
else:
log.info(
u'Updated request for credit with UUID "%s" so the user can re-issue the request',
credit_request.uuid
)
# Sign the parameters using a secret key we share with the credit provider.
parameters["signature"] = signature(parameters, shared_secret_key)
return {
"url": credit_provider.provider_url,
"method": "POST",
"parameters": parameters
}
def update_credit_request_status(request_uuid, status):
def update_credit_request_status(request_uuid, provider_id, status):
"""
Update the status of a credit request.
......@@ -272,12 +337,13 @@ def update_credit_request_status(request_uuid, status):
Arguments:
request_uuid (str): The unique identifier for the credit request.
provider_id (str): Identifier for the credit provider.
status (str): Either "approved" or "rejected"
Returns: None
Raises:
CreditRequestNotFound: The request does not exist.
CreditRequestNotFound: No request exists that is associated with the given provider.
InvalidCreditStatus: The status is not either "approved" or "rejected".
"""
......@@ -285,11 +351,23 @@ def update_credit_request_status(request_uuid, status):
raise InvalidCreditStatus
try:
request = CreditRequest.objects.get(uuid=request_uuid)
request = CreditRequest.objects.get(uuid=request_uuid, provider__provider_id=provider_id)
old_status = request.status
request.status = status
request.save()
log.info(
u'Updated request with UUID "%s" from status "%s" to "%s" for provider with ID "%s".',
request_uuid, old_status, status, provider_id
)
except CreditRequest.DoesNotExist:
raise CreditRequestNotFound
msg = (
u'Credit provider with ID "{provider_id}" attempted to '
u'update request with UUID "{request_uuid}", but no request '
u'with this UUID is associated with the provider.'
).format(provider_id=provider_id, request_uuid=request_uuid)
log.warning(msg)
raise CreditRequestNotFound(msg)
def get_credit_requests_for_user(username):
......
"""Exceptions raised by the credit API. """
class InvalidCreditRequirements(Exception):
class CreditApiBadRequest(Exception):
"""
Could not complete a request to the credit API because
there was a problem with the request (as opposed to an internal error).
"""
pass
class InvalidCreditRequirements(CreditApiBadRequest):
"""
The requirement dictionary provided has invalid format.
"""
pass
class InvalidCreditCourse(Exception):
class InvalidCreditCourse(CreditApiBadRequest):
"""
The course is not configured for credit.
"""
pass
class UserIsNotEligible(Exception):
class UserIsNotEligible(CreditApiBadRequest):
"""
The user has not satisfied eligibility requirements for credit.
"""
pass
class RequestAlreadyCompleted(Exception):
class CreditProviderNotConfigured(CreditApiBadRequest):
"""
The requested credit provider is not configured correctly for the course.
"""
pass
class RequestAlreadyCompleted(CreditApiBadRequest):
"""
The user has already submitted a request and received a response from the credit provider.
"""
pass
class CreditRequestNotFound(Exception):
class CreditRequestNotFound(CreditApiBadRequest):
"""
The request does not exist.
"""
pass
class InvalidCreditStatus(Exception):
class InvalidCreditStatus(CreditApiBadRequest):
"""
The status is not either "approved" or "rejected".
"""
......
# -*- 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):
# Removing unique constraint on 'CreditProvider', fields ['provider_url']
db.delete_unique('credit_creditprovider', ['provider_url'])
# Adding field 'CreditProvider.enable_integration'
db.add_column('credit_creditprovider', 'enable_integration',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
# Changing field 'CreditProvider.provider_url'
db.alter_column('credit_creditprovider', 'provider_url', self.gf('django.db.models.fields.URLField')(max_length=200))
def backwards(self, orm):
# Deleting field 'CreditProvider.enable_integration'
db.delete_column('credit_creditprovider', 'enable_integration')
# Changing field 'CreditProvider.provider_url'
db.alter_column('credit_creditprovider', 'provider_url', self.gf('django.db.models.fields.URLField')(max_length=255, unique=True))
# Adding unique constraint on 'CreditProvider', fields ['provider_url']
db.create_unique('credit_creditprovider', ['provider_url'])
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'}),
'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'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': "''", 'max_length': '200'})
},
'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,7 @@ successful completion of a course on EdX
import logging
from django.db import models
from django.core.validators import RegexValidator
from simple_history.models import HistoricalRecords
......@@ -29,10 +30,53 @@ class CreditProvider(TimeStampedModel):
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,
unique=True,
validators=[
RegexValidator(
regex=r"^[a-z,A-Z,0-9,\-]+$",
message="Only alphanumeric characters and hyphens (-) are allowed",
code="invalid_provider_id",
)
],
help_text=ugettext_lazy(
"Unique identifier for this credit provider. "
"Only alphanumeric characters and hyphens (-) are allowed. "
"The identifier is case-sensitive."
)
)
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="")
active = models.BooleanField(
default=True,
help_text=ugettext_lazy("Whether the credit provider is currently enabled.")
)
display_name = models.CharField(
max_length=255,
help_text=ugettext_lazy("Name of the credit provider displayed to users")
)
enable_integration = models.BooleanField(
default=False,
help_text=ugettext_lazy(
"When true, automatically notify the credit provider "
"when a user requests credit. "
"In order for this to work, a shared secret key MUST be configured "
"for the credit provider in secure auth settings."
)
)
provider_url = models.URLField(
default="",
help_text=ugettext_lazy(
"URL of the credit provider. If automatic integration is "
"enabled, this will the the end-point that we POST to "
"to notify the provider of a credit request. Otherwise, the "
"user will be shown a link to this URL, so the user can "
"request credit from the provider directly."
)
)
# Default is one year
DEFAULT_ELIGIBILITY_DURATION = 31556970
......@@ -41,7 +85,6 @@ class CreditProvider(TimeStampedModel):
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):
......
"""
Calculate digital signatures for messages sent to/from credit providers,
using a shared secret key.
The signature is calculated as follows:
1) Encode all parameters of the request (except the signature) in a string.
2) Encode each key/value pair as a string of the form "{key}:{value}".
3) Concatenate key/value pairs in ascending alphabetical order by key.
4) Calculate the HMAC-SHA256 digest of the encoded request parameters, using a 32-character shared secret key.
5) Encode the digest in hexadecimal.
It is the responsibility of the credit provider to check the signature of messages
we send them, and it is our responsibility to check the signature of messages
we receive from the credit provider.
"""
import hashlib
import hmac
from django.conf import settings
def get_shared_secret_key(provider_id):
"""
Retrieve the shared secret key for a particular credit provider.
"""
return getattr(settings, "CREDIT_PROVIDER_SECRET_KEYS", {}).get(provider_id)
def signature(params, shared_secret):
"""
Calculate the digital signature for parameters using a shared secret.
Arguments:
params (dict): Parameters to sign. Ignores the "signature" key if present.
Returns:
str: The 32-character signature.
"""
encoded_params = "".join([
"{key}:{value}".format(key=key, value=params[key])
for key in sorted(params.keys())
if key != "signature"
])
hasher = hmac.new(shared_secret, encoded_params, hashlib.sha256)
return hasher.hexdigest()
......@@ -7,6 +7,7 @@ import ddt
import pytz
import dateutil.parser as date_parser
from django.test import TestCase
from django.test.utils import override_settings
from django.db import connection, transaction
from opaque_keys.edx.keys import CourseKey
......@@ -30,6 +31,12 @@ from openedx.core.djangoapps.credit.models import (
)
TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
@override_settings(CREDIT_PROVIDER_SECRET_KEYS={
"hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY
})
class CreditApiTestBase(TestCase):
"""
Base class for test cases of the credit API.
......@@ -37,6 +44,7 @@ class CreditApiTestBase(TestCase):
PROVIDER_ID = "hogwarts"
PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry"
PROVIDER_URL = "https://credit.example.com/request"
def setUp(self, **kwargs):
super(CreditApiTestBase, self).setUp()
......@@ -47,7 +55,12 @@ class CreditApiTestBase(TestCase):
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_provider = CreditProvider.objects.create(
provider_id=self.PROVIDER_ID,
display_name=self.PROVIDER_NAME,
provider_url=self.PROVIDER_URL,
enable_integration=True,
)
credit_course.providers.add(credit_provider)
return credit_course
......@@ -228,40 +241,63 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
# Initiate a credit request
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username'])
# Validate the URL and method
self.assertIn('url', request)
self.assertEqual(request['url'], self.PROVIDER_URL)
self.assertIn('method', request)
self.assertEqual(request['method'], "POST")
self.assertIn('parameters', request)
parameters = request['parameters']
# Validate the UUID
self.assertIn('uuid', request)
self.assertEqual(len(request['uuid']), 32)
self.assertIn('request_uuid', parameters)
self.assertEqual(len(parameters['request_uuid']), 32)
# Validate the timestamp
self.assertIn('timestamp', request)
parsed_date = date_parser.parse(request['timestamp'])
self.assertIn('timestamp', parameters)
parsed_date = date_parser.parse(parameters['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)
self.assertIn('course_org', parameters)
self.assertEqual(parameters['course_org'], self.course_key.org)
self.assertIn('course_num', parameters)
self.assertEqual(parameters['course_num'], self.course_key.course)
self.assertIn('course_run', parameters)
self.assertEqual(parameters['course_run'], self.course_key.run)
self.assertIn('final_grade', parameters)
self.assertEqual(parameters['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])
param_key = 'user_{key}'.format(key=key)
self.assertIn(param_key, parameters)
self.assertEqual(parameters[param_key], self.USER_INFO[key])
def test_credit_request_disable_integration(self):
CreditProvider.objects.all().update(enable_integration=False)
# Initiate a request with automatic integration disabled
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username'])
# We get a URL and a GET method, so we can provide students
# with a link to the credit provider, where they can request
# credit directly.
self.assertIn("url", request)
self.assertEqual(request["url"], self.PROVIDER_URL)
self.assertIn("method", request)
self.assertEqual(request["method"], "GET")
@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'])
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)
api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, status)
self._assert_credit_status(status)
def test_query_counts(self):
......@@ -277,34 +313,38 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
# - 3 queries: Retrieve and update the request
# - 1 query: Update the history table for the request.
uuid = request["parameters"]["request_uuid"]
with self.assertNumQueries(4):
api.update_credit_request_status(request['uuid'], "approved")
api.update_credit_request_status(uuid, self.PROVIDER_ID, "approved")
with self.assertNumQueries(1):
api.get_credit_requests_for_user(self.USER_INFO['username'])
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'])
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'])
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'])
self.assertEqual(
first_request["parameters"]["request_uuid"],
second_request["parameters"]["request_uuid"]
)
# Request should use the updated information
self.assertEqual(second_request['user_full_name'], "Bobby")
self.assertEqual(second_request["parameters"]["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'])
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)
api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, status)
# Attempting a second request raises an exception
with self.assertRaises(RequestAlreadyCompleted):
......@@ -328,8 +368,8 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
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"], "")
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"])
self.assertEqual(request["parameters"]["user_mailing_address"], "")
def test_create_request_null_country(self):
# Simulate users who registered accounts before the country field was introduced.
......@@ -340,8 +380,8 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
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"], "")
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"])
self.assertEqual(request["parameters"]["user_country"], "")
def test_user_has_no_final_grade(self):
# Simulate an error condition that should never happen:
......@@ -356,21 +396,21 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
grade_status.save()
with self.assertRaises(UserIsNotEligible):
api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username'])
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'])
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")
api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, "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")
api.update_credit_request_status("invalid_uuid", self.PROVIDER_ID, "approved")
def test_get_credit_requests_no_requests(self):
requests = api.get_credit_requests_for_user(self.USER_INFO['username'])
requests = api.get_credit_requests_for_user(self.USER_INFO["username"])
self.assertEqual(requests, [])
def _configure_credit(self):
......@@ -389,7 +429,7 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
active=True
)
status = CreditRequirementStatus.objects.create(
username=self.USER_INFO['username'],
username=self.USER_INFO["username"],
requirement=requirement,
)
status.status = "satisfied"
......@@ -397,12 +437,12 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
status.save()
CreditEligibility.objects.create(
username=self.USER_INFO['username'],
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'])
statuses = api.get_credit_requests_for_user(self.USER_INFO["username"])
self.assertEqual(statuses[0]["status"], expected_status)
"""
Tests for credit app views.
"""
import unittest
import json
import datetime
import pytz
import ddt
from mock import patch
from django.test import TestCase
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.conf import settings
from student.tests.factories import UserFactory
from util.testing import UrlResetMixin
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.credit import api
from openedx.core.djangoapps.credit.signature import signature
from openedx.core.djangoapps.credit.models import (
CreditCourse,
CreditProvider,
CreditRequirement,
CreditRequirementStatus,
CreditEligibility,
CreditRequest,
)
TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
@ddt.ddt
@override_settings(CREDIT_PROVIDER_SECRET_KEYS={
"hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY
})
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CreditProviderViewTests(UrlResetMixin, TestCase):
"""
Tests for HTTP end-points used to issue requests to credit providers
and receive responses approving or denying requests.
"""
USERNAME = "ron"
USER_FULL_NAME = "Ron Weasley"
PASSWORD = "password"
PROVIDER_ID = "hogwarts"
PROVIDER_URL = "https://credit.example.com/request"
COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course")
FINAL_GRADE = 0.95
@patch.dict(settings.FEATURES, {"ENABLE_CREDIT_API": True})
def setUp(self):
"""
Configure a credit course.
"""
super(CreditProviderViewTests, self).setUp()
# Create the test user and log in
self.user = UserFactory(username=self.USERNAME, password=self.PASSWORD)
self.user.profile.name = self.USER_FULL_NAME
self.user.profile.save()
success = self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.assertTrue(success, msg="Could not log in")
# Enable the course for credit
credit_course = CreditCourse.objects.create(
course_key=self.COURSE_KEY,
enabled=True,
)
# Configure a credit provider for the course
credit_provider = CreditProvider.objects.create(
provider_id=self.PROVIDER_ID,
enable_integration=True,
provider_url=self.PROVIDER_URL,
)
credit_course.providers.add(credit_provider)
credit_course.save()
# Add a single credit requirement (final grade)
requirement = CreditRequirement.objects.create(
course=credit_course,
namespace="grade",
name="grade",
)
# Mark the user as having satisfied the requirement
# and eligible for credit.
CreditRequirementStatus.objects.create(
username=self.USERNAME,
requirement=requirement,
status="satisfied",
reason={"final_grade": self.FINAL_GRADE}
)
CreditEligibility.objects.create(
username=self.USERNAME,
course=credit_course,
provider=credit_provider,
)
def test_credit_request_and_response(self):
# Initiate a request
response = self._create_credit_request(self.USERNAME, self.COURSE_KEY)
self.assertEqual(response.status_code, 200)
# Check that the user's request status is pending
requests = api.get_credit_requests_for_user(self.USERNAME)
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["status"], "pending")
# Check request parameters
content = json.loads(response.content)
self.assertEqual(content["url"], self.PROVIDER_URL)
self.assertEqual(content["method"], "POST")
self.assertEqual(len(content["parameters"]["request_uuid"]), 32)
self.assertEqual(content["parameters"]["course_org"], "edX")
self.assertEqual(content["parameters"]["course_num"], "DemoX")
self.assertEqual(content["parameters"]["course_run"], "Demo_Course")
self.assertEqual(content["parameters"]["final_grade"], self.FINAL_GRADE)
self.assertEqual(content["parameters"]["user_username"], self.USERNAME)
self.assertEqual(content["parameters"]["user_full_name"], self.USER_FULL_NAME)
self.assertEqual(content["parameters"]["user_mailing_address"], "")
self.assertEqual(content["parameters"]["user_country"], "")
# The signature is going to change each test run because the request
# is assigned a different UUID each time.
# For this reason, we use the signature function directly
# (the "signature" parameter will be ignored when calculating the signature).
# Other unit tests verify that the signature function is working correctly.
self.assertEqual(
content["parameters"]["signature"],
signature(content["parameters"], TEST_CREDIT_PROVIDER_SECRET_KEY)
)
# Simulate a response from the credit provider
response = self._credit_provider_callback(
content["parameters"]["request_uuid"],
"approved"
)
self.assertEqual(response.status_code, 200)
# Check that the user's status is approved
requests = api.get_credit_requests_for_user(self.USERNAME)
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["status"], "approved")
def test_request_credit_anonymous_user(self):
self.client.logout()
response = self._create_credit_request(self.USERNAME, self.COURSE_KEY)
self.assertEqual(response.status_code, 403)
def test_request_credit_for_another_user(self):
response = self._create_credit_request("another_user", self.COURSE_KEY)
self.assertEqual(response.status_code, 403)
@ddt.data(
# Invalid JSON
"{",
# Missing required parameters
json.dumps({"username": USERNAME}),
json.dumps({"course_key": unicode(COURSE_KEY)}),
# Invalid course key format
json.dumps({"username": USERNAME, "course_key": "invalid"}),
)
def test_create_credit_request_invalid_parameters(self, request_data):
url = reverse("credit:create_request", args=[self.PROVIDER_ID])
response = self.client.post(url, data=request_data, content_type="application/json")
self.assertEqual(response.status_code, 400)
def test_credit_provider_callback_validates_signature(self):
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)
# Simulate a callback from the credit provider with an invalid signature
# Since the signature is invalid, we respond with a 403 Not Authorized.
response = self._credit_provider_callback(request_uuid, "approved", sig="invalid")
self.assertEqual(response.status_code, 403)
def test_credit_provider_callback_validates_timestamp(self):
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)
# Simulate a callback from the credit provider with a timestamp too far in the past
# (slightly more than 15 minutes)
# Since the message isn't timely, respond with a 403.
timestamp = datetime.datetime.now(pytz.UTC) - datetime.timedelta(0, 60 * 15 + 1)
response = self._credit_provider_callback(request_uuid, "approved", timestamp=timestamp.isoformat())
self.assertEqual(response.status_code, 403)
def test_credit_provider_callback_is_idempotent(self):
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)
# Initially, the status should be "pending"
self._assert_request_status(request_uuid, "pending")
# First call sets the status to approved
self._credit_provider_callback(request_uuid, "approved")
self._assert_request_status(request_uuid, "approved")
# Second call succeeds as well; status is still approved
self._credit_provider_callback(request_uuid, "approved")
self._assert_request_status(request_uuid, "approved")
@ddt.data(
# Invalid JSON
"{",
# Not a dictionary
"4",
# Invalid timestamp format
json.dumps({
"request_uuid": "557168d0f7664fe59097106c67c3f847",
"status": "approved",
"timestamp": "invalid",
"signature": "7685ae1c8f763597ee7ce526685c5ac24353317dbfe087f0ed32a699daf7dc63",
}),
)
def test_credit_provider_callback_invalid_parameters(self, request_data):
url = reverse("credit:provider_callback", args=[self.PROVIDER_ID])
response = self.client.post(url, data=request_data, content_type="application/json")
self.assertEqual(response.status_code, 400)
def test_credit_provider_invalid_status(self):
response = self._credit_provider_callback("557168d0f7664fe59097106c67c3f847", "invalid")
self.assertEqual(response.status_code, 400)
def test_credit_provider_key_not_configured(self):
# Cannot initiate a request because we can't sign it
with override_settings(CREDIT_PROVIDER_SECRET_KEYS={}):
response = self._create_credit_request(self.USERNAME, self.COURSE_KEY)
self.assertEqual(response.status_code, 400)
# Create the request with the secret key configured
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)
# Callback from the provider is not authorized, because
# the shared secret isn't configured.
with override_settings(CREDIT_PROVIDER_SECRET_KEYS={}):
response = self._credit_provider_callback(request_uuid, "approved")
self.assertEqual(response.status_code, 403)
def test_request_associated_with_another_provider(self):
other_provider_id = "other_provider"
other_provider_secret_key = "1d01f067a5a54b0b8059f7095a7c636d"
# Create an additional credit provider and associate it with the course.
credit_course = CreditCourse.objects.get(course_key=self.COURSE_KEY)
credit_provider = CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True)
credit_course.providers.add(credit_provider)
credit_course.save()
# Initiate a credit request with the first provider
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)
# Attempt to update the request status for a different provider
with override_settings(CREDIT_PROVIDER_SECRET_KEYS={other_provider_id: other_provider_secret_key}):
response = self._credit_provider_callback(
request_uuid,
"approved",
provider_id=other_provider_id,
secret_key=other_provider_secret_key,
)
# Response should be a 404 to avoid leaking request UUID values to other providers.
self.assertEqual(response.status_code, 404)
# Request status should still be "pending"
self._assert_request_status(request_uuid, "pending")
def _create_credit_request(self, username, course_key):
"""
Initiate a request for credit.
"""
url = reverse("credit:create_request", args=[self.PROVIDER_ID])
return self.client.post(
url,
data=json.dumps({
"username": username,
"course_key": unicode(course_key),
}),
content_type="application/json",
)
def _create_credit_request_and_get_uuid(self, username, course_key):
"""
Initiate a request for credit and return the request UUID.
"""
response = self._create_credit_request(username, course_key)
self.assertEqual(response.status_code, 200)
return json.loads(response.content)["parameters"]["request_uuid"]
def _credit_provider_callback(self, request_uuid, status, **kwargs):
"""
Simulate a response from the credit provider approving
or rejecting the credit request.
Arguments:
request_uuid (str): The UUID of the credit request.
status (str): The status of the credit request.
Keyword Arguments:
provider_id (str): Identifier for the credit provider.
secret_key (str): Shared secret key for signing messages.
timestamp (datetime): Timestamp of the message.
sig (str): Digital signature to use on messages.
"""
provider_id = kwargs.get("provider_id", self.PROVIDER_ID)
secret_key = kwargs.get("secret_key", TEST_CREDIT_PROVIDER_SECRET_KEY)
timestamp = kwargs.get("timestamp", datetime.datetime.now(pytz.UTC).isoformat())
url = reverse("credit:provider_callback", args=[provider_id])
parameters = {
"request_uuid": request_uuid,
"status": status,
"timestamp": timestamp,
}
parameters["signature"] = kwargs.get("sig", signature(parameters, secret_key))
return self.client.post(url, data=json.dumps(parameters), content_type="application/json")
def _assert_request_status(self, uuid, expected_status):
"""
Check the status of a credit request.
"""
request = CreditRequest.objects.get(uuid=uuid)
self.assertEqual(request.status, expected_status)
"""
URLs for the credit app.
"""
from django.conf.urls import patterns, url
from .views import create_credit_request, credit_provider_callback
urlpatterns = patterns(
'',
url(
r"^v1/provider/(?P<provider_id>[^/]+)/request/$",
create_credit_request,
name="create_request"
),
url(
r"^v1/provider/(?P<provider_id>[^/]+)/callback/?$",
credit_provider_callback,
name="provider_callback"
),
)
"""
Views for the credit Django app.
"""
import json
import datetime
import logging
import dateutil
import pytz
from django.http import (
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
Http404
)
from django.views.decorators.http import require_POST
from django.conf import settings
from opaque_keys.edx.keys import CourseKey
from opaque_keys import InvalidKeyError
from util.json_request import JsonResponse
from openedx.core.djangoapps.credit import api
from openedx.core.djangoapps.credit.signature import signature, get_shared_secret_key
from openedx.core.djangoapps.credit.exceptions import CreditApiBadRequest, CreditRequestNotFound
log = logging.getLogger(__name__)
@require_POST
def create_credit_request(request, provider_id):
"""
Initiate a request for credit in a course.
This end-point will get-or-create a record in the database to track
the request. It will then calculate the parameters to send to
the credit provider and digitially sign the parameters, using a secret
key shared with the credit provider.
The user's browser is responsible for POSTing these parameters
directly to the credit provider.
**Example Usage:**
POST /api/credit/v1/provider/hogwarts/request/
{
"username": "ron",
"course_key": "edX/DemoX/Demo_Course"
}
Response: 200 OK
Content-Type: application/json
{
"url": "http://example.com/request-credit",
"method": "POST",
"parameters": {
request_uuid: "557168d0f7664fe59097106c67c3f847"
timestamp: "2015-05-04T20:57:57.987119+00:00"
course_org: "ASUx"
course_num: "DemoX"
course_run: "1T2015"
final_grade: 0.95,
user_username: "john",
user_email: "john@example.com"
user_full_name: "John Smith"
user_mailing_address: "",
user_country: "US",
signature: "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
}
}
**Parameters:**
* username (unicode): The username of the user requesting credit.
* course_key (unicode): The identifier for the course for which the user
is requesting credit.
**Responses:**
* 200 OK: The request was created successfully. Returned content
is a JSON-encoded dictionary describing what the client should
send to the credit provider.
* 400 Bad Request:
- The provided course key did not correspond to a valid credit course.
- The user already has a completed credit request for this course and provider.
* 403 Not Authorized:
- The username does not match the name of the logged in user.
- The user is not eligible for credit in the course.
* 404 Not Found:
- The provider does not exist.
"""
response, parameters = _validate_json_parameters(request.body, ["username", "course_key"])
if response is not None:
return response
try:
course_key = CourseKey.from_string(parameters["course_key"])
except InvalidKeyError:
return HttpResponseBadRequest(
u'Could not parse "{course_key}" as a course key'.format(
course_key=parameters["course_key"]
)
)
# Check user authorization
if not (request.user and request.user.username == parameters["username"]):
log.warning(
u'User with ID %s attempted to initiate a credit request for user with username "%s"',
request.user.id if request.user else "[Anonymous]",
parameters["username"]
)
return HttpResponseForbidden("Users are not allowed to initiate credit requests for other users.")
# Initiate the request
try:
credit_request = api.create_credit_request(course_key, provider_id, parameters["username"])
except CreditApiBadRequest as ex:
return HttpResponseBadRequest(ex)
else:
return JsonResponse(credit_request)
@require_POST
def credit_provider_callback(request, provider_id):
"""
Callback end-point used by credit providers to approve or reject
a request for credit.
**Example Usage:**
POST /api/credit/v1/provider/{provider-id}/callback
{
"request_uuid": "557168d0f7664fe59097106c67c3f847",
"status": "approved",
"timestamp": "2015-05-04T20:57:57.987119+00:00",
"signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
}
Response: 200 OK
**Parameters:**
* request_uuid (string): The UUID of the request.
* status (string): Either "approved" or "rejected".
* timestamp (string): The datetime at which the POST request was made, in ISO 8601 format.
This will always include time-zone information.
* signature (string): A digital signature of the request parameters,
created using a secret key shared with the credit provider.
**Responses:**
* 200 OK: The user's status was updated successfully.
* 400 Bad request: The provided parameters were not valid.
Response content will be a JSON-encoded string describing the error.
* 403 Forbidden: Signature was invalid or timestamp was too far in the past.
* 404 Not Found: Could not find a request with the specified UUID associated with this provider.
"""
response, parameters = _validate_json_parameters(request.body, [
"request_uuid", "status", "timestamp", "signature"
])
if response is not None:
return response
# Validate the digital signature of the request.
# This ensures that the message came from the credit provider
# and hasn't been tampered with.
response = _validate_signature(parameters, provider_id)
if response is not None:
return response
# Validate the timestamp to ensure that the request is timely.
response = _validate_timestamp(parameters["timestamp"], provider_id)
if response is not None:
return response
# Update the credit request status
try:
api.update_credit_request_status(parameters["request_uuid"], provider_id, parameters["status"])
except CreditRequestNotFound:
raise Http404
except CreditApiBadRequest as ex:
return HttpResponseBadRequest(ex)
else:
return HttpResponse()
def _validate_json_parameters(params_string, expected_parameters):
"""
Load the request parameters as a JSON dictionary and check that
all required paramters are present.
Arguments:
params_string (unicode): The JSON-encoded parameter dictionary.
expected_parameters (list): Required keys of the parameters dictionary.
Returns: tuple of (HttpResponse, dict)
"""
try:
parameters = json.loads(params_string)
except (TypeError, ValueError):
return HttpResponseBadRequest("Could not parse the request body as JSON."), None
if not isinstance(parameters, dict):
return HttpResponseBadRequest("Request parameters must be a JSON-encoded dictionary."), None
missing_params = set(expected_parameters) - set(parameters.keys())
if missing_params:
msg = u"Required parameters are missing: {missing}".format(missing=u", ".join(missing_params))
return HttpResponseBadRequest(msg), None
return None, parameters
def _validate_signature(parameters, provider_id):
"""
Check that the signature from the credit provider is valid.
Arguments:
parameters (dict): Parameters received from the credit provider.
provider_id (unicode): Identifier for the credit provider.
Returns:
HttpResponseForbidden or None
"""
secret_key = get_shared_secret_key(provider_id)
if secret_key is None:
log.error(
(
u'Could not retrieve secret key for credit provider with ID "%s". '
u'Since no key has been configured, we cannot validate requests from the credit provider.'
), provider_id
)
return HttpResponseForbidden("Credit provider credentials have not been configured.")
if signature(parameters, secret_key) != parameters["signature"]:
log.warning(u'Request from credit provider with ID "%s" had an invalid signature', parameters["signature"])
return HttpResponseForbidden("Invalid signature.")
def _validate_timestamp(timestamp_str, provider_id):
"""
Check that the timestamp of the request is recent.
Arguments:
timestamp_str (str): ISO-8601 datetime formatted string.
provider_id (unicode): Identifier for the credit provider.
Returns:
HttpResponse or None
"""
# If we can't parse the datetime string, reject the request.
try:
# dateutil's parser has some counter-intuitive behavior:
# for example, given an empty string or "a" it always returns the current datetime.
# It is the responsibility of the credit provider to send a valid ISO-8601 datetime
# so we can validate it; otherwise, this check might not take effect.
# (Note that the signature check ensures that the timestamp we receive hasn't
# been tampered with after being issued by the credit provider).
timestamp = dateutil.parser.parse(timestamp_str)
except ValueError:
msg = u'"{timestamp}" is not an ISO-8601 formatted datetime'.format(timestamp=timestamp_str)
log.warning(msg)
return HttpResponseBadRequest(msg)
# Check that the timestamp is recent
elapsed_seconds = (datetime.datetime.now(pytz.UTC) - timestamp).total_seconds()
if elapsed_seconds > settings.CREDIT_PROVIDER_TIMESTAMP_EXPIRATION:
log.warning(
(
u'Timestamp %s is too far in the past (%s seconds), '
u'so we are rejecting the notification from the credit provider "%s".'
),
timestamp_str, elapsed_seconds, provider_id,
)
return HttpResponseForbidden(u"Timestamp is too far in the past.")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment