The update (PUT) endpoint now supports creating new CreditCourse objects in addition to updating existing ones. This functionality greatly simplifies the logic needed by consumers (e.g. the Course Administration Tool) to create or update CreditCourse objects. ECOM-2524
408 lines
14 KiB
Python
408 lines
14 KiB
Python
"""
|
|
Views for the credit Django app.
|
|
"""
|
|
import datetime
|
|
import json
|
|
import logging
|
|
|
|
from django.conf import settings
|
|
from django.http import (
|
|
HttpResponse,
|
|
HttpResponseBadRequest,
|
|
HttpResponseForbidden,
|
|
Http404
|
|
)
|
|
from django.utils.decorators import method_decorator
|
|
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
|
|
from rest_framework.authentication import SessionAuthentication
|
|
from rest_framework_oauth.authentication import OAuth2Authentication
|
|
|
|
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
|
|
from openedx.core.lib.api.mixins import PutAsCreateMixin
|
|
from util.date_utils import from_timestamp
|
|
from util.json_request import JsonResponse
|
|
|
|
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_ids = request.GET.get("provider_ids", None)
|
|
providers_list = provider_ids.split(",") if provider_ids 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(PutAsCreateMixin, 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 = (OAuth2Authentication, SessionAuthentication,)
|
|
permission_classes = (permissions.IsAuthenticated, permissions.IsAdminUser)
|
|
|
|
# In Django Rest Framework v3, there is a default pagination
|
|
# class that transmutes the response data into a dictionary
|
|
# with pagination information. The original response data (a list)
|
|
# is stored in a "results" value of the dictionary.
|
|
# For backwards compatibility with the existing API, we disable
|
|
# the default behavior by setting the pagination_class to None.
|
|
pagination_class = None
|
|
|
|
# This CSRF exemption only applies when authenticating without SessionAuthentication.
|
|
# SessionAuthentication will enforce CSRF protection.
|
|
@method_decorator(csrf_exempt)
|
|
def dispatch(self, request, *args, **kwargs):
|
|
return super(CreditCourseViewSet, self).dispatch(request, *args, **kwargs)
|
|
|
|
def get_object(self):
|
|
# Convert the serialized course key into a CourseKey instance
|
|
# so we can look up the object.
|
|
course_key = self.kwargs.get(self.lookup_field)
|
|
if course_key is not None:
|
|
self.kwargs[self.lookup_field] = CourseKey.from_string(course_key)
|
|
|
|
return super(CreditCourseViewSet, self).get_object()
|