Endpoints support create, read, and list operations. NOTE: This commit also includes a retrofitted SimpleRouter that supports overriding the lookup regex. This retrofit is simpler to implement than updating edx-ora2 which is pinned to DRF 2.3.x. XCOM-524
390 lines
13 KiB
Python
390 lines
13 KiB
Python
"""
|
|
Views for the credit Django app.
|
|
"""
|
|
import json
|
|
import datetime
|
|
import logging
|
|
|
|
from django.conf import settings
|
|
from django.http import (
|
|
HttpResponse,
|
|
HttpResponseBadRequest,
|
|
HttpResponseForbidden,
|
|
Http404
|
|
)
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.decorators.http import require_POST, require_GET
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey
|
|
import pytz
|
|
from rest_framework import viewsets, mixins, permissions, authentication
|
|
|
|
from util.json_request import JsonResponse
|
|
from util.date_utils import from_timestamp
|
|
from openedx.core.djangoapps.credit import api
|
|
from openedx.core.djangoapps.credit.exceptions import CreditApiBadRequest, CreditRequestNotFound
|
|
from openedx.core.djangoapps.credit.models import CreditCourse
|
|
from openedx.core.djangoapps.credit.serializers import CreditCourseSerializer
|
|
from openedx.core.djangoapps.credit.signature import signature, get_shared_secret_key
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@require_GET
|
|
def get_providers_detail(request):
|
|
"""
|
|
|
|
**User Cases**
|
|
|
|
Returns details of the credit providers filtered by provided query parameters.
|
|
|
|
**Parameters:**
|
|
|
|
* provider_id (list of provider ids separated with ","): The identifiers for the providers for which
|
|
user requested
|
|
|
|
**Example Usage:**
|
|
|
|
GET /api/credit/v1/providers?provider_id=asu,hogwarts
|
|
"response": [
|
|
"id": "hogwarts",
|
|
"display_name": "Hogwarts School of Witchcraft and Wizardry",
|
|
"url": "https://credit.example.com/",
|
|
"status_url": "https://credit.example.com/status/",
|
|
"description": "A new model for the Witchcraft and Wizardry School System.",
|
|
"enable_integration": false,
|
|
"fulfillment_instructions": "
|
|
<p>In order to fulfill credit, Hogwarts School of Witchcraft and Wizardry requires learners to:</p>
|
|
<ul>
|
|
<li>Sample instruction abc</li>
|
|
<li>Sample instruction xyz</li>
|
|
</ul>",
|
|
},
|
|
...
|
|
]
|
|
|
|
**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.
|
|
|
|
* 404 Not Found: The provider does not exist.
|
|
|
|
"""
|
|
provider_id = request.GET.get("provider_id", None)
|
|
providers_list = provider_id.split(",") if provider_id else None
|
|
providers = api.get_credit_providers(providers_list)
|
|
return JsonResponse(providers)
|
|
|
|
|
|
@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 digitally 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/providers/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: 1434631630,
|
|
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
|
|
@csrf_exempt
|
|
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/providers/{provider-id}/callback
|
|
{
|
|
"request_uuid": "557168d0f7664fe59097106c67c3f847",
|
|
"status": "approved",
|
|
"timestamp": 1434631630,
|
|
"signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
|
|
}
|
|
|
|
Response: 200 OK
|
|
|
|
**Parameters:**
|
|
|
|
* request_uuid (string): The UUID of the request.
|
|
|
|
* status (string): Either "approved" or "rejected".
|
|
|
|
* timestamp (int or string): The datetime at which the POST request was made, represented
|
|
as the number of seconds since January 1, 1970 00:00:00 UTC.
|
|
If the timestamp is a string, it will be converted to an integer.
|
|
|
|
* 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()
|
|
|
|
|
|
@require_GET
|
|
def get_eligibility_for_user(request):
|
|
"""
|
|
|
|
**User Cases**
|
|
|
|
Retrieve user eligibility against course.
|
|
|
|
**Parameters:**
|
|
|
|
* course_key (unicode): Identifier of course.
|
|
* username (unicode): Username of current User.
|
|
|
|
**Example Usage:**
|
|
|
|
GET /api/credit/v1/eligibility?username=user&course_key=edX/Demo_101/Fall
|
|
"response": {
|
|
"course_key": "edX/Demo_101/Fall",
|
|
"deadline": "2015-10-23"
|
|
}
|
|
|
|
**Responses:**
|
|
|
|
* 200 OK: The request was created successfully.
|
|
|
|
* 404 Not Found: The provider does not exist.
|
|
|
|
"""
|
|
course_key = request.GET.get("course_key", None)
|
|
username = request.GET.get("username", None)
|
|
return JsonResponse(api.get_eligibilities_for_user(username=username, course_key=course_key))
|
|
|
|
|
|
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_value, provider_id):
|
|
"""
|
|
Check that the timestamp of the request is recent.
|
|
|
|
Arguments:
|
|
timestamp (int or string): Number of seconds since Jan. 1, 1970 UTC.
|
|
If specified as a string, it will be converted to an integer.
|
|
provider_id (unicode): Identifier for the credit provider.
|
|
|
|
Returns:
|
|
HttpResponse or None
|
|
|
|
"""
|
|
timestamp = from_timestamp(timestamp_value)
|
|
if timestamp is None:
|
|
msg = u'"{timestamp}" is not a valid timestamp'.format(timestamp=timestamp_value)
|
|
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_value, elapsed_seconds, provider_id,
|
|
)
|
|
return HttpResponseForbidden(u"Timestamp is too far in the past.")
|
|
|
|
|
|
class CreditCourseViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|
""" CreditCourse endpoints. """
|
|
|
|
lookup_field = 'course_key'
|
|
lookup_value_regex = settings.COURSE_KEY_REGEX
|
|
queryset = CreditCourse.objects.all()
|
|
serializer_class = CreditCourseSerializer
|
|
authentication_classes = (authentication.OAuth2Authentication, authentication.SessionAuthentication,)
|
|
permission_classes = (permissions.IsAuthenticated, permissions.IsAdminUser)
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Convert the course ID/key from a string to an actual CourseKey object.
|
|
course_id = kwargs.get(self.lookup_field, None)
|
|
|
|
if course_id:
|
|
kwargs[self.lookup_field] = CourseKey.from_string(course_id)
|
|
|
|
return super(CreditCourseViewSet, self).dispatch(request, *args, **kwargs)
|