Skip to content
Snippets Groups Projects
Commit 92153752 authored by Clinton Blackburn's avatar Clinton Blackburn
Browse files

Rewrote Credit API

- API built atop Django REST Framework
- Added support for OAuth 2.0 and session authentication
- Added permissions around eligibility data

ECOM-2609
parent 5043b465
No related merge requests found
Showing
with 860 additions and 889 deletions
......@@ -393,9 +393,6 @@ FEATURES = {
# Enable OpenBadge support. See the BADGR_* settings later in this file.
'ENABLE_OPENBADGES': False,
# Credit course API
'ENABLE_CREDIT_API': True,
# The block types to disable need to be specified in "x block disable config" in django admin.
'ENABLE_DISABLING_XBLOCK_TYPES': True,
......
......@@ -68,8 +68,6 @@ FEATURES['ENABLE_SHOPPING_CART'] = True
FEATURES['ENABLE_VERIFIED_CERTIFICATES'] = True
FEATURES['ENABLE_CREDIT_API'] = True
# Enable this feature for course staff grade downloads, to enable acceptance tests
FEATURES['ENABLE_S3_GRADE_DOWNLOADS'] = True
FEATURES['ALLOW_COURSE_STAFF_GRADE_DOWNLOADS'] = True
......
......@@ -16,6 +16,8 @@ var edx = edx || {};
headers: {
'X-CSRFToken': $.cookie('csrftoken')
},
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({
'course_key': courseKey,
'username': username
......
......@@ -140,7 +140,11 @@ var edx = edx || {};
return $.ajax({
url: _.sprintf(providerUrl, providerId),
type: 'GET',
dataType: 'json'
dataType: 'json',
contentType: 'application/json',
headers: {
'X-CSRFToken': $.cookie('csrftoken')
}
}).retry({times: 5, timeout: 2000, statusCodes: [404]});
},
/**
......
......@@ -7,8 +7,8 @@
</div>
<div class="provider-more-info">
<%= interpolate(
gettext("To finalize course credit, %(provider_id)s requires %(platform_name)s learners to submit a credit request."),
{ provider_id: provider_id.toUpperCase(), platform_name: platformName }, true
gettext("To finalize course credit, %(display_name)s requires %(platform_name)s learners to submit a credit request."),
{ display_name: display_name, platform_name: platformName }, true
) %>
</div>
<div class="provider-instructions">
......@@ -21,7 +21,7 @@
<%= interpolate("<img src='%s' alt='%s'></image>", [thumbnail_url, display_name]) %>
</div>
<div class="complete-order">
<%= interpolate('<button data-provider="%s" data-course-key="%s" data-username="%s" class="complete-course" onClick=completeOrder(this)>%s</button>', [provider_id, course_key, username,
<%= interpolate('<button data-provider="%s" data-course-key="%s" data-username="%s" class="complete-course" onClick=completeOrder(this)>%s</button>', [id, course_key, username,
gettext( "Get Credit")]) %>
</div>
</div>
......@@ -98,6 +98,7 @@ urlpatterns = (
url(r'^api/val/v0/', include('edxval.urls')),
url(r'^api/commerce/', include('commerce.api.urls', namespace='commerce_api')),
url(r'^api/credit/', include('openedx.core.djangoapps.credit.urls', app_name="credit", namespace='credit')),
)
if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]:
......@@ -115,12 +116,6 @@ else:
url(r'^register$', 'student.views.register_user', name="register_user"),
)
if settings.FEATURES.get("ENABLE_CREDIT_API"):
# Credit API end-points
urlpatterns += (
url(r'^api/credit/', include('openedx.core.djangoapps.credit.urls', app_name="credit", namespace='credit')),
)
if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
urlpatterns += (
url(r'^api/mobile/v0.5/', include('mobile_api.urls')),
......
......@@ -5,6 +5,8 @@ whether a user has satisfied those requirements.
import logging
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse
from openedx.core.djangoapps.credit.email_utils import send_credit_notifications
from openedx.core.djangoapps.credit.models import (
......@@ -14,8 +16,7 @@ from openedx.core.djangoapps.credit.models import (
CreditEligibility,
)
from opaque_keys.edx.keys import CourseKey
# TODO: Cleanup this mess! ECOM-2908
log = logging.getLogger(__name__)
......@@ -246,8 +247,7 @@ def set_credit_requirement_status(username, course_key, req_namespace, req_name,
# Find the requirement we're trying to set
req_to_update = next((
req for req in reqs
if req.namespace == req_namespace
and req.name == req_name
if req.namespace == req_namespace and req.name == req_name
), None)
# If we can't find the requirement, then the most likely explanation
......
......@@ -4,12 +4,12 @@ API for initiating and tracking requests for credit from a provider.
import datetime
import logging
import pytz
import uuid
import pytz
from django.db import transaction
from lms.djangoapps.django_comment_client.utils import JsonResponse
from lms.djangoapps.django_comment_client.utils import JsonResponse
from openedx.core.djangoapps.credit.exceptions import (
UserIsNotEligible,
CreditProviderNotConfigured,
......@@ -28,6 +28,8 @@ from student.models import User
from util.date_utils import to_timestamp
# TODO: Cleanup this mess! ECOM-2908
log = logging.getLogger(__name__)
......@@ -257,12 +259,10 @@ def create_credit_request(course_key, provider_id, username):
final_grade = unicode(final_grade)
except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError):
log.exception(
"Could not retrieve final grade from the credit eligibility table "
"for user %s in course %s.",
user.id, course_key
)
raise UserIsNotEligible
msg = 'Could not retrieve final grade from the credit eligibility table for ' \
'user [{user_id}] in course [{course_key}].'.format(user_id=user.id, course_key=course_key)
log.exception(msg)
raise UserIsNotEligible(msg)
parameters = {
"request_uuid": credit_request.uuid,
......
"""Exceptions raised by the credit API. """
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from rest_framework import status
from rest_framework.exceptions import APIException
# TODO: Cleanup this mess! ECOM-2908
class CreditApiBadRequest(Exception):
......@@ -56,3 +62,25 @@ class InvalidCreditStatus(CreditApiBadRequest):
The status is not either "approved" or "rejected".
"""
pass
class InvalidCreditRequest(APIException):
""" API request is invalid. """
status_code = status.HTTP_400_BAD_REQUEST
class UserNotEligibleException(InvalidCreditRequest):
""" User not eligible for credit for a given course. """
def __init__(self, course_key, username):
detail = _('[{username}] is not eligible for credit for [{course_key}].').format(username=username,
course_key=course_key)
super(UserNotEligibleException, self).__init__(detail)
class InvalidCourseKey(InvalidCreditRequest):
""" Course key is invalid. """
def __init__(self, course_key):
detail = _('[{course_key}] is not a valid course key.').format(course_key=course_key)
super(InvalidCourseKey, self).__init__(detail)
......@@ -25,6 +25,7 @@ from xmodule_django.models import CourseKeyField
from django.utils.translation import ugettext_lazy
CREDIT_PROVIDER_ID_REGEX = r"[a-z,A-Z,0-9,\-]+"
log = logging.getLogger(__name__)
......@@ -42,7 +43,7 @@ class CreditProvider(TimeStampedModel):
unique=True,
validators=[
RegexValidator(
regex=r"^[a-z,A-Z,0-9,\-]+$",
regex=CREDIT_PROVIDER_ID_REGEX,
message="Only alphanumeric characters and hyphens (-) are allowed",
code="invalid_provider_id",
)
......@@ -498,10 +499,7 @@ def default_deadline_for_credit_eligibility(): # pylint: disable=invalid-name
class CreditEligibility(TimeStampedModel):
"""
A record of a user's eligibility for credit from a specific credit
provider for a specific course.
"""
""" A record of a user's eligibility for credit for a specific course. """
username = models.CharField(max_length=255, db_index=True)
course = models.ForeignKey(CreditCourse, related_name="eligibilities")
......
""" Credit API Serializers """
from rest_framework import serializers
from __future__ import unicode_literals
import datetime
import logging
from opaque_keys.edx.keys import CourseKey
from django.conf import settings
from opaque_keys import InvalidKeyError
from openedx.core.djangoapps.credit.models import CreditCourse
from opaque_keys.edx.keys import CourseKey
import pytz
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider, CreditEligibility, CreditRequest
from openedx.core.djangoapps.credit.signature import get_shared_secret_key, signature
from util.date_utils import from_timestamp
log = logging.getLogger(__name__)
class CourseKeyField(serializers.Field):
"""
Serializer field for a model CourseKey field.
"""
""" Serializer field for a model CourseKey field. """
def to_representation(self, data):
"""Convert a course key to unicode. """
......@@ -32,3 +41,81 @@ class CreditCourseSerializer(serializers.ModelSerializer):
class Meta(object):
model = CreditCourse
exclude = ('id',)
class CreditProviderSerializer(serializers.ModelSerializer):
""" CreditProvider """
id = serializers.CharField(source='provider_id') # pylint:disable=invalid-name
description = serializers.CharField(source='provider_description')
status_url = serializers.URLField(source='provider_status_url')
url = serializers.URLField(source='provider_url')
class Meta(object):
model = CreditProvider
fields = ('id', 'display_name', 'url', 'status_url', 'description', 'enable_integration',
'fulfillment_instructions', 'thumbnail_url',)
class CreditEligibilitySerializer(serializers.ModelSerializer):
""" CreditEligibility serializer. """
course_key = serializers.SerializerMethodField()
def get_course_key(self, obj):
""" Returns the course key associated with the course. """
return unicode(obj.course.course_key)
class Meta(object):
model = CreditEligibility
fields = ('username', 'course_key', 'deadline',)
class CreditProviderCallbackSerializer(serializers.Serializer): # pylint:disable=abstract-method
"""
Serializer for input to the CreditProviderCallback view.
This is used solely for validating the input.
"""
request_uuid = serializers.CharField(required=True)
status = serializers.ChoiceField(required=True, choices=CreditRequest.REQUEST_STATUS_CHOICES)
timestamp = serializers.IntegerField(required=True)
signature = serializers.CharField(required=True)
def __init__(self, **kwargs):
self.provider = kwargs.pop('provider', None)
super(CreditProviderCallbackSerializer, self).__init__(**kwargs)
def validate_timestamp(self, value):
""" Ensure the request has been received in a timely manner. """
date_time = from_timestamp(value)
# Ensure we converted the timestamp to a datetime
if not date_time:
msg = '[{}] is not a valid timestamp'.format(value)
log.warning(msg)
raise serializers.ValidationError(msg)
elapsed = (datetime.datetime.now(pytz.UTC) - date_time).total_seconds()
if elapsed > settings.CREDIT_PROVIDER_TIMESTAMP_EXPIRATION:
msg = '[{value}] is too far in the past (over [{elapsed}] seconds).'.format(value=value, elapsed=elapsed)
log.warning(msg)
raise serializers.ValidationError(msg)
return value
def validate_signature(self, value):
""" Validate the signature and ensure the provider is setup properly. """
provider_id = self.provider.provider_id
secret_key = get_shared_secret_key(provider_id)
if secret_key is None:
msg = 'Could not retrieve secret key for credit provider [{}]. ' \
'Unable to validate requests from provider.'.format(provider_id)
log.error(msg)
raise PermissionDenied(msg)
data = self.initial_data
actual_signature = data["signature"]
if signature(data, secret_key) != actual_signature:
msg = 'Request from credit provider [{}] had an invalid signature.'.format(provider_id)
raise PermissionDenied(msg)
return value
......@@ -59,5 +59,5 @@ def signature(params, shared_secret):
for key in sorted(params.keys())
if key != u"signature"
])
hasher = hmac.new(shared_secret, encoded_params.encode('utf-8'), hashlib.sha256)
hasher = hmac.new(shared_secret.encode('utf-8'), encoded_params.encode('utf-8'), hashlib.sha256)
return hasher.hexdigest()
......@@ -2,13 +2,9 @@
This file contains celery tasks for credit course views.
"""
import datetime
from pytz import UTC
from django.conf import settings
from celery import task
from celery.utils.log import get_task_logger
from django.conf import settings
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
......
# pylint:disable=missing-docstring,no-member
import datetime
import json
import uuid # pylint:disable=unused-import
from django.contrib.auth.models import User
import factory
from factory.fuzzy import FuzzyText
import pytz
from openedx.core.djangoapps.credit.models import CreditProvider, CreditEligibility, CreditCourse, CreditRequest
from util.date_utils import to_timestamp
class CreditCourseFactory(factory.DjangoModelFactory):
class Meta(object):
model = CreditCourse
course_key = FuzzyText(prefix='fake.org/', suffix='/fake.run')
enabled = True
class CreditProviderFactory(factory.DjangoModelFactory):
class Meta(object):
model = CreditProvider
provider_id = FuzzyText(length=5)
provider_url = FuzzyText(prefix='http://')
class CreditEligibilityFactory(factory.DjangoModelFactory):
class Meta(object):
model = CreditEligibility
course = factory.SubFactory(CreditCourseFactory)
class CreditRequestFactory(factory.DjangoModelFactory):
class Meta(object):
model = CreditRequest
uuid = factory.LazyAttribute(lambda o: uuid.uuid4().hex) # pylint: disable=undefined-variable
# pylint: disable=access-member-before-definition,attribute-defined-outside-init,no-self-argument,unused-argument
@factory.post_generation
def post(obj, create, extracted, **kwargs):
"""
Post-generation handler.
Sets up parameters field.
"""
if not obj.parameters:
course_key = obj.course.course_key
user = User.objects.get(username=obj.username)
user_profile = user.profile
# pylint:disable=access-member-before-definition
obj.parameters = json.dumps({
"request_uuid": obj.uuid,
"timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)),
"course_org": course_key.org,
"course_num": course_key.course,
"course_run": course_key.run,
"final_grade": '0.96',
"user_username": user.username,
"user_email": user.email,
"user_full_name": user_profile.name,
"user_mailing_address": "",
"user_country": user_profile.country.code or "",
})
obj.save()
......@@ -2,17 +2,13 @@
Tests for the API functions in the credit app.
"""
import datetime
import json
import unittest
import ddt
from django.conf import settings
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
from django.db import connection, transaction
from django.core.urlresolvers import reverse, NoReverseMatch
from mock import patch
from opaque_keys.edx.keys import CourseKey
import pytz
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
......@@ -39,8 +35,6 @@ from student.tests.factories import UserFactory
TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
from util.testing import UrlResetMixin
@override_settings(CREDIT_PROVIDER_SECRET_KEYS={
"hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY,
......@@ -880,119 +874,3 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
"""Check the user's credit status. """
statuses = api.get_credit_requests_for_user(self.USER_INFO["username"])
self.assertEqual(statuses[0]["status"], expected_status)
class CreditApiFeatureFlagTest(UrlResetMixin, TestCase):
"""
Base class to test the credit api urls.
"""
def setUp(self, **kwargs):
enable_credit_api = kwargs.get('enable_credit_api', False)
with patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREDIT_API': enable_credit_api}):
super(CreditApiFeatureFlagTest, self).setUp('lms.urls')
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CreditApiFeatureFlagDisabledTests(CreditApiFeatureFlagTest):
"""
Test Python API for credit provider api with feature flag
'ENABLE_CREDIT_API' disabled.
"""
PROVIDER_ID = "hogwarts"
def setUp(self):
super(CreditApiFeatureFlagDisabledTests, self).setUp(enable_credit_api=False)
def test_get_credit_provider_details(self):
"""
Test that 'get_provider_info' api url not found.
"""
with self.assertRaises(NoReverseMatch):
reverse('credit:get_provider_info', args=[self.PROVIDER_ID])
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CreditApiFeatureFlagEnabledTests(CreditApiFeatureFlagTest, CreditApiTestBase):
"""
Test Python API for credit provider api with feature flag
'ENABLE_CREDIT_API' enabled.
"""
USER_INFO = {
"username": "bob",
"email": "bob@example.com",
"full_name": "Bob",
"mailing_address": "123 Fake Street, Cambridge MA",
"country": "US",
}
FINAL_GRADE = 0.95
def setUp(self):
super(CreditApiFeatureFlagEnabledTests, self).setUp(enable_credit_api=True)
self.user = UserFactory(
username=self.USER_INFO['username'],
email=self.USER_INFO['email'],
)
self.user.profile.name = self.USER_INFO['full_name']
self.user.profile.mailing_address = self.USER_INFO['mailing_address']
self.user.profile.country = self.USER_INFO['country']
self.user.profile.save()
# By default, configure the database so that there is a single
# credit requirement that the user has satisfied (minimum grade)
self._configure_credit()
def test_get_credit_provider_details(self):
"""Test that credit api method 'test_get_credit_provider_details'
returns dictionary data related to provided credit provider.
"""
expected_result = {
"provider_id": self.PROVIDER_ID,
"display_name": self.PROVIDER_NAME,
"provider_url": self.PROVIDER_URL,
"provider_status_url": self.PROVIDER_STATUS_URL,
"provider_description": self.PROVIDER_DESCRIPTION,
"enable_integration": self.ENABLE_INTEGRATION,
"fulfillment_instructions": self.FULFILLMENT_INSTRUCTIONS,
"thumbnail_url": self.THUMBNAIL_URL
}
path = reverse('credit:get_provider_info', kwargs={'provider_id': self.PROVIDER_ID})
result = self.client.get(path)
result = json.loads(result.content)
self.assertEqual(result, expected_result)
# now test that user gets empty dict for non existent credit provider
path = reverse('credit:get_provider_info', kwargs={'provider_id': 'fake_provider_id'})
result = self.client.get(path)
result = json.loads(result.content)
self.assertEqual(result, {})
def _configure_credit(self):
"""
Configure a credit course and its requirements.
By default, add a single requirement (minimum grade)
that the user has satisfied.
"""
credit_course = self.add_credit_course()
requirement = CreditRequirement.objects.create(
course=credit_course,
namespace="grade",
name="grade",
active=True
)
status = CreditRequirementStatus.objects.create(
username=self.USER_INFO["username"],
requirement=requirement,
)
status.status = "satisfied"
status.reason = {"final_grade": self.FINAL_GRADE}
status.save()
CreditEligibility.objects.create(
username=self.USER_INFO['username'],
course=CreditCourse.objects.get(course_key=self.course_key)
)
......@@ -5,7 +5,6 @@ Tests for credit course models.
import ddt
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.credit.models import CreditCourse, CreditRequirement
......@@ -17,7 +16,7 @@ class CreditEligibilityModelTests(TestCase):
Tests for credit models used to track credit eligibility.
"""
def setUp(self, **kwargs):
def setUp(self):
super(CreditEligibilityModelTests, self).setUp()
self.course_key = CourseKey.from_string("edX/DemoX/Demo_Course")
......
""" Tests for Credit API serializers. """
# pylint: disable=no-member
from __future__ import unicode_literals
from django.test import TestCase
from openedx.core.djangoapps.credit import serializers
from openedx.core.djangoapps.credit.tests.factories import CreditProviderFactory, CreditEligibilityFactory
from student.tests.factories import UserFactory
class CreditProviderSerializerTests(TestCase):
""" CreditProviderSerializer tests. """
def test_data(self):
""" Verify the correct fields are serialized. """
provider = CreditProviderFactory(active=False)
serializer = serializers.CreditProviderSerializer(provider)
expected = {
'id': provider.provider_id,
'display_name': provider.display_name,
'url': provider.provider_url,
'status_url': provider.provider_status_url,
'description': provider.provider_description,
'enable_integration': provider.enable_integration,
'fulfillment_instructions': provider.fulfillment_instructions,
'thumbnail_url': provider.thumbnail_url,
}
self.assertDictEqual(serializer.data, expected)
class CreditEligibilitySerializerTests(TestCase):
""" CreditEligibilitySerializer tests. """
def test_data(self):
""" Verify the correct fields are serialized. """
user = UserFactory()
eligibility = CreditEligibilityFactory(username=user.username)
serializer = serializers.CreditEligibilitySerializer(eligibility)
expected = {
'course_key': unicode(eligibility.course.course_key),
'deadline': eligibility.deadline.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'username': user.username,
}
self.assertDictEqual(serializer.data, expected)
This diff is collapsed.
......@@ -3,47 +3,25 @@ URLs for the credit app.
"""
from django.conf.urls import patterns, url, include
from openedx.core.djangoapps.credit import views, routers
from openedx.core.djangoapps.credit.api.provider import get_credit_provider_info
from openedx.core.djangoapps.credit import views, routers, models
PROVIDER_ID_PATTERN = r'(?P<provider_id>[^/]+)'
PROVIDER_ID_PATTERN = r'(?P<provider_id>{})'.format(models.CREDIT_PROVIDER_ID_REGEX)
V1_URLS = patterns(
PROVIDER_URLS = patterns(
'',
url(r'^request/$', views.CreditProviderRequestCreateView.as_view(), name='create_request'),
url(r'^callback/?$', views.CreditProviderCallbackView.as_view(), name='provider_callback'),
)
url(
r'^providers/$',
views.get_providers_detail,
name='providers_detail'
),
url(
r'^providers/{provider_id}/$'.format(provider_id=PROVIDER_ID_PATTERN),
get_credit_provider_info,
name='get_provider_info'
),
url(
r'^providers/{provider_id}/request/$'.format(provider_id=PROVIDER_ID_PATTERN),
views.create_credit_request,
name='create_request'
),
url(
r'^providers/{provider_id}/callback/?$'.format(provider_id=PROVIDER_ID_PATTERN),
views.credit_provider_callback,
name='provider_callback'
),
url(
r'^eligibility/$',
views.get_eligibility_for_user,
name='eligibility_details'
),
V1_URLS = patterns(
'',
url(r'^providers/{}/'.format(PROVIDER_ID_PATTERN), include(PROVIDER_URLS)),
url(r'^eligibility/$', views.CreditEligibilityView.as_view(), name='eligibility_details'),
)
router = routers.SimpleRouter() # pylint: disable=invalid-name
router.register(r'courses', views.CreditCourseViewSet)
router.register(r'providers', views.CreditProviderViewSet)
V1_URLS += router.urls
urlpatterns = patterns(
......
"""
Views for the credit Django app.
"""
import datetime
import json
from __future__ import unicode_literals
import logging
import datetime
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 import viewsets, mixins, permissions, views, generics
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
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.djangoapps.credit.api import create_credit_request
from openedx.core.djangoapps.credit.exceptions import (UserNotEligibleException, InvalidCourseKey, CreditApiBadRequest,
InvalidCreditRequest)
from openedx.core.djangoapps.credit.models import (CreditCourse, CreditProvider, CREDIT_PROVIDER_ID_REGEX,
CreditEligibility, CreditRequest)
from openedx.core.djangoapps.credit.serializers import (CreditCourseSerializer, CreditProviderSerializer,
CreditEligibilitySerializer, CreditProviderCallbackSerializer)
from openedx.core.lib.api.mixins import PutAsCreateMixin
from util.date_utils import from_timestamp
from util.json_request import JsonResponse
from openedx.core.lib.api.permissions import IsStaffOrOwner
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.
class CreditProviderViewSet(viewsets.ReadOnlyModelViewSet):
""" Credit provider endpoints. """
* 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:**
lookup_field = 'provider_id'
lookup_value_regex = CREDIT_PROVIDER_ID_REGEX
authentication_classes = (OAuth2Authentication, SessionAuthentication,)
pagination_class = None
permission_classes = (permissions.IsAuthenticated,)
queryset = CreditProvider.objects.all()
serializer_class = CreditProviderSerializer
* course_key (unicode): Identifier of course.
* username (unicode): Username of current User.
def filter_queryset(self, queryset):
queryset = super(CreditProviderViewSet, self).filter_queryset(queryset)
**Example Usage:**
# Filter by provider ID
provider_ids = self.request.GET.get('provider_ids', None)
GET /api/credit/v1/eligibility?username=user&course_key=edX/Demo_101/Fall
"response": {
"course_key": "edX/Demo_101/Fall",
"deadline": "2015-10-23"
}
if provider_ids:
provider_ids = provider_ids.split(',')
queryset = queryset.filter(provider_id__in=provider_ids)
**Responses:**
return queryset
* 200 OK: The request was created successfully.
* 404 Not Found: The provider does not exist.
class CreditProviderRequestCreateView(views.APIView):
""" Creates a credit request for the given user and course, if the user is eligible for credit."""
"""
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))
authentication_classes = (OAuth2Authentication, SessionAuthentication,)
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
def post(self, request, provider_id):
""" POST handler. """
# Get the provider, or return HTTP 404 if it doesn't exist
provider = generics.get_object_or_404(CreditProvider, provider_id=provider_id)
def _validate_json_parameters(params_string, expected_parameters):
"""
Load the request parameters as a JSON dictionary and check that
all required paramters are present.
# Validate the course key
course_key = request.data.get('course_key')
try:
course_key = CourseKey.from_string(course_key)
except InvalidKeyError:
raise InvalidCourseKey(course_key)
Arguments:
params_string (unicode): The JSON-encoded parameter dictionary.
expected_parameters (list): Required keys of the parameters dictionary.
# Validate the username
username = request.data.get('username')
if not username:
raise ValidationError({'detail': 'A username must be specified.'})
Returns: tuple of (HttpResponse, dict)
# Ensure the user is actually eligible to receive credit
if not CreditEligibility.is_user_eligible_for_credit(course_key, username):
raise UserNotEligibleException(course_key, username)
"""
try:
parameters = json.loads(params_string)
except (TypeError, ValueError):
return HttpResponseBadRequest("Could not parse the request body as JSON."), None
try:
credit_request = create_credit_request(course_key, provider.provider_id, username)
return Response(credit_request)
except CreditApiBadRequest as ex:
raise InvalidCreditRequest(ex.message)
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
class CreditProviderCallbackView(views.APIView):
""" Callback used by credit providers to update credit request status. """
return None, parameters
# This endpoint should be open to all external credit providers.
authentication_classes = ()
permission_classes = ()
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super(CreditProviderCallbackView, self).dispatch(request, *args, **kwargs)
def post(self, request, provider_id):
""" POST handler. """
provider = generics.get_object_or_404(CreditProvider, provider_id=provider_id)
data = request.data
# Ensure the input data is valid
serializer = CreditProviderCallbackSerializer(data=data, provider=provider)
serializer.is_valid(raise_exception=True)
# Update the credit request status
request_uuid = data['request_uuid']
new_status = data['status']
credit_request = generics.get_object_or_404(CreditRequest, uuid=request_uuid, provider=provider)
old_status = credit_request.status
credit_request.status = new_status
credit_request.save()
log.info(
'Updated [%s] CreditRequest [%s] from status [%s] to [%s].',
provider_id, request_uuid, old_status, new_status
)
def _validate_signature(parameters, provider_id):
"""
Check that the signature from the credit provider is valid.
return Response()
Arguments:
parameters (dict): Parameters received from the credit provider.
provider_id (unicode): Identifier for the credit provider.
Returns:
HttpResponseForbidden or None
class CreditEligibilityView(generics.ListAPIView):
""" Returns eligibility for a user-course combination. """
"""
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,
authentication_classes = (OAuth2Authentication, SessionAuthentication,)
pagination_class = None
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner)
serializer_class = CreditEligibilitySerializer
queryset = CreditEligibility.objects.all()
def filter_queryset(self, queryset):
username = self.request.GET.get('username')
course_key = self.request.GET.get('course_key')
if not (username and course_key):
raise ValidationError(
{'detail': 'Both the course_key and username querystring parameters must be supplied.'})
course_key = unicode(course_key)
try:
course_key = CourseKey.from_string(course_key)
except InvalidKeyError:
raise ValidationError({'detail': '[{}] is not a valid course key.'.format(course_key)})
return queryset.filter(
username=username,
course__course_key=course_key,
deadline__gt=datetime.datetime.now(pytz.UTC)
)
return HttpResponseForbidden(u"Timestamp is too far in the past.")
class CreditCourseViewSet(PutAsCreateMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment