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,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):
......
"""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".
"""
......
......@@ -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."
)
)
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")
)
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="")
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()
"""
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"
),
)
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