Files
edx-platform/openedx/core/djangoapps/credit/views.py
Will Daly 8988aa1c9e 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).
2015-06-11 11:34:25 -04:00

293 lines
10 KiB
Python

"""
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.")