Credit provider integration Python API
* Add end-point for initiating a request for credit from a provider. * Add an end-point for a provider to update the status of a request (approved / denied).
This commit is contained in:
@@ -989,3 +989,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
|
||||
@@ -2461,7 +2463,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.
|
||||
@@ -2470,3 +2472,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')),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
""" 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,23 +175,29 @@ 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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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):
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
"""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".
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# -*- 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):
|
||||
|
||||
49
openedx/core/djangoapps/credit/signature.py
Normal file
49
openedx/core/djangoapps/credit/signature.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
332
openedx/core/djangoapps/credit/tests/test_views.py
Normal file
332
openedx/core/djangoapps/credit/tests/test_views.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
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)
|
||||
22
openedx/core/djangoapps/credit/urls.py
Normal file
22
openedx/core/djangoapps/credit/urls.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
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"
|
||||
),
|
||||
)
|
||||
292
openedx/core/djangoapps/credit/views.py
Normal file
292
openedx/core/djangoapps/credit/views.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
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.")
|
||||
Reference in New Issue
Block a user