* 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).
293 lines
10 KiB
Python
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.")
|