""" 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": "

In order to fulfill credit, Hogwarts School of Witchcraft and Wizardry requires learners to:

", }, ... ] **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)