Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
28 changes: 28 additions & 0 deletions lms/djangoapps/badges/api/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
Serializers for Badges
"""
from rest_framework import serializers

from badges.models import BadgeClass, BadgeAssertion


class BadgeClassSerializer(serializers.ModelSerializer):
"""
Serializer for BadgeClass model.
"""
image_url = serializers.ImageField(source='image')

class Meta(object):
model = BadgeClass
fields = ('slug', 'issuing_component', 'display_name', 'course_id', 'description', 'criteria', 'image_url')


class BadgeAssertionSerializer(serializers.ModelSerializer):
"""
Serializer for the BadgeAssertion model.
"""
badge_class = BadgeClassSerializer(read_only=True)

class Meta(object):
model = BadgeAssertion
fields = ('badge_class', 'image_url', 'assertion_url')
233 changes: 233 additions & 0 deletions lms/djangoapps/badges/api/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""
Tests for the badges API views.
"""
from django.conf import settings
from django.test.utils import override_settings

from badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory
from openedx.core.lib.api.test_utils import ApiTestCase
from student.tests.factories import UserFactory
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory

FEATURES_WITH_BADGES_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True


@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase):
"""
Mixin for badge API tests.
"""
WILDCARD = False
CHECK_COURSE = False

def setUp(self, *args, **kwargs):
super(UserAssertionTestCase, self).setUp(*args, **kwargs)
self.course = CourseFactory.create()
self.user = UserFactory.create()
# Password defined by factory.
self.client.login(username=self.user.username, password='test')

def url(self):
"""
Return the URL to look up the current user's assertions.
"""
return '/api/badges/v1/assertions/user/{}/'.format(self.user.username)

def check_class_structure(self, badge_class, json_class):
"""
Check a JSON response against a known badge class.
"""
self.assertEqual(badge_class.issuing_component, json_class['issuing_component'])
self.assertEqual(badge_class.slug, json_class['slug'])
self.assertIn(badge_class.image.url, json_class['image_url'])
self.assertEqual(badge_class.description, json_class['description'])
self.assertEqual(badge_class.criteria, json_class['criteria'])
self.assertEqual(badge_class.course_id and unicode(badge_class.course_id), json_class['course_id'])

def check_assertion_structure(self, assertion, json_assertion):
"""
Check a JSON response against a known assertion object.
"""
self.assertEqual(assertion.image_url, json_assertion['image_url'])
self.assertEqual(assertion.assertion_url, json_assertion['assertion_url'])
self.check_class_structure(assertion.badge_class, json_assertion['badge_class'])

def get_course_id(self, badge_class):
"""
Used for tests which may need to test for a course_id or a wildcard.
"""
if self.WILDCARD:
return '*'
else:
return unicode(badge_class.course_id)

def create_badge_class(self, **kwargs):
"""
Create a badge class, using a course id if it's relevant to the URL pattern.
"""
if self.CHECK_COURSE:
return RandomBadgeClassFactory.create(course_id=self.course.location.course_key, **kwargs)
return RandomBadgeClassFactory.create(**kwargs)

def get_qs_args(self, badge_class):
"""
Get a dictionary to be serialized into querystring params based on class settings.
"""
qs_args = {
'issuing_component': badge_class.issuing_component,
'slug': badge_class.slug,
}
if self.CHECK_COURSE:
qs_args['course_id'] = self.get_course_id(badge_class)
return qs_args


class TestUserBadgeAssertions(UserAssertionTestCase):
"""
Test the general badge assertions retrieval view.
"""

def test_get_assertions(self):
"""
Verify we can get all of a user's badge assertions.
"""
for dummy in range(3):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The convention in python is to use __ for dummy, unused variables.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't mentioned in the list of unused variable patterns, while dummy is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first character listed in that link is _. So that's what I'm referring to. And since we want to distinguish between the _ that refers to ugettext, I usually use double underscore instead.

But yeah, dummy also works as an old-style convention.

BadgeAssertionFactory(user=self.user)
# Add in a course scoped badge-- these should not be excluded from the full listing.
BadgeAssertionFactory(user=self.user, badge_class=BadgeClassFactory(course_id=self.course.location.course_key))
# Should not be included.
for dummy in range(3):
self.create_badge_class()
response = self.get_json(self.url())
# pylint: disable=no-member
self.assertEqual(len(response['results']), 4)

def test_assertion_structure(self):
badge_class = self.create_badge_class()
assertion = BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
response = self.get_json(self.url())
# pylint: disable=no-member
self.check_assertion_structure(assertion, response['results'][0])


class TestUserCourseBadgeAssertions(UserAssertionTestCase):
"""
Test the Badge Assertions view with the course_id filter.
"""
CHECK_COURSE = True

def test_get_assertions(self):
"""
Verify we can get assertions via the course_id and username.
"""
course_key = self.course.location.course_key
badge_class = BadgeClassFactory.create(course_id=course_key)
for dummy in range(3):
BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
# Should not be included.
for dummy in range(3):
BadgeAssertionFactory.create(user=self.user)
# Also should not be included
for dummy in range(6):
BadgeAssertionFactory.create(badge_class=badge_class)
response = self.get_json(self.url(), data={'course_id': course_key})
# pylint: disable=no-member
self.assertEqual(len(response['results']), 3)
unused_course = CourseFactory.create()
response = self.get_json(self.url(), data={'course_id': unused_course.location.course_key})
# pylint: disable=no-member
self.assertEqual(len(response['results']), 0)

def test_assertion_structure(self):
"""
Verify the badge assertion structure is not mangled in this mode.
"""
course_key = self.course.location.course_key
badge_class = BadgeClassFactory.create(course_id=course_key)
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
response = self.get_json(self.url())
# pylint: disable=no-member
self.check_assertion_structure(assertion, response['results'][0])


class TestUserBadgeAssertionsByClass(UserAssertionTestCase):
"""
Test the Badge Assertions view with the badge class filter.
"""

def test_get_assertions(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It would be ideal (from a DRY perspective) if these multiple test_get_assertions implementations can be consolidated.

"""
Verify we can get assertions via the badge class and username.
"""
badge_class = self.create_badge_class()
for dummy in range(3):
BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
if badge_class.course_id:
# Also create a version of this badge under a different course.
alt_class = BadgeClassFactory.create(
slug=badge_class.slug, issuing_component=badge_class.issuing_component,
course_id=CourseFactory.create().location.course_key
)
BadgeAssertionFactory.create(user=self.user, badge_class=alt_class)
# Should not be in list.
for dummy in range(5):
BadgeAssertionFactory.create(badge_class=badge_class)
# Also should not be in list.
for dummy in range(6):
BadgeAssertionFactory.create()

response = self.get_json(
self.url(),
data=self.get_qs_args(badge_class),
)
if self.WILDCARD:
expected_length = 4
else:
expected_length = 3
# pylint: disable=no-member
self.assertEqual(len(response['results']), expected_length)
unused_class = self.create_badge_class(slug='unused_slug', issuing_component='unused_component')

response = self.get_json(
self.url(),
data=self.get_qs_args(unused_class),
)
# pylint: disable=no-member
self.assertEqual(len(response['results']), 0)

def check_badge_class_assertion(self, badge_class):
"""
Given a badge class, create an assertion for the current user and fetch it, checking the structure.
"""
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
response = self.get_json(
self.url(),
data=self.get_qs_args(badge_class),
)
# pylint: disable=no-member
self.check_assertion_structure(assertion, response['results'][0])

def test_assertion_structure(self):
self.check_badge_class_assertion(self.create_badge_class())

def test_empty_issuing_component(self):
self.check_badge_class_assertion(self.create_badge_class(issuing_component=''))


# pylint: disable=test-inherits-tests
class TestUserBadgeAssertionsByClassCourse(TestUserBadgeAssertionsByClass):
"""
Test searching all assertions for a user with a course bound badge class.
"""
CHECK_COURSE = True


# pylint: disable=test-inherits-tests
class TestUserBadgeAssertionsByClassWildCard(TestUserBadgeAssertionsByClassCourse):
"""
Test searching slugs/issuing_components across all course IDs.
"""
WILDCARD = True
12 changes: 12 additions & 0 deletions lms/djangoapps/badges/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""
URLs for badges API
"""
from django.conf.urls import patterns, url

from .views import UserBadgeAssertions
from openedx.core.djangoapps.user_api.urls import USERNAME_PATTERN

urlpatterns = patterns(
'badges.views',
url('^assertions/user/' + USERNAME_PATTERN + '/$', UserBadgeAssertions.as_view(), name='user-assertions'),
)
121 changes: 121 additions & 0 deletions lms/djangoapps/badges/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
API views for badges
"""
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import generics
from rest_framework.exceptions import APIException

from badges.models import BadgeAssertion
from openedx.core.lib.api.view_utils import view_auth_classes
from .serializers import BadgeAssertionSerializer
from xmodule_django.models import CourseKeyField


class CourseKeyError(APIException):
"""
Raised the course key given isn't valid.
"""
status_code = 400
default_detail = "The course key provided could not be parsed."


@view_auth_classes(is_user=True)
class UserBadgeAssertions(generics.ListAPIView):
"""
** Use cases **

Request a list of assertions for a user, optionally constrained to a course.

** Example Requests **

GET /api/badges/v1/assertions/user/{username}/

** Response Values **

Body comprised of a list of objects with the following fields:

* badge_class: The badge class the assertion was awarded for. Represented as an object
with the following fields:
* slug: The identifier for the badge class
* issuing_component: The software component responsible for issuing this badge.
* display_name: The display name of the badge.
* course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course.
* description: A description of the award and its significance.
* criteria: A description of what is needed to obtain this award.
* image_url: A URL to the icon image used to represent this award.
* image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award
in its headers.
* assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools
and software.

** Params **

* slug (optional): The identifier for a particular badge class to filter by.
* issuing_component (optional): The issuing component for a particular badge class to filter by
(requires slug to have been specified, or this will be ignored.) If slug is provided and this is not,
assumes the issuing_component should be empty.
* course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is
provided, and this field is not specified, assumes that the target badge has an empty course_id field.
'*' may be used to get all badges with the specified slug, issuing_component combination across all courses.

** Returns **

* 200 on success, with a list of Badge Assertion objects.
* 403 if a user who does not have permission to masquerade as
another user specifies a username other than their own.
* 404 if the specified user does not exist

{
"count": 7,
"previous": null,
"num_pages": 1,
"results": [
{
"badge_class": {
"slug": "special_award",
"issuing_component": "edx__course",
"display_name": "Very Special Award",
"course_id": "course-v1:edX+DemoX+Demo_Course",
"description": "Awarded for people who did something incredibly special",
"criteria": "Do something incredibly special.",
"image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png"
},
"image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png",
"assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6"
},
...
]
}
"""
serializer_class = BadgeAssertionSerializer

def get_queryset(self):
"""
Get all badges for the username specified.
"""
queryset = BadgeAssertion.objects.filter(user__username=self.kwargs['username'])
provided_course_id = self.request.query_params.get('course_id')
if provided_course_id == '*':
# We might want to get all the matching course scoped badges to see how many courses
# a user managed to get a specific award on.
course_id = None
elif provided_course_id:
try:
course_id = CourseKey.from_string(provided_course_id)
except InvalidKeyError:
raise CourseKeyError
elif 'slug' not in self.request.query_params:
# Need to get all badges for the user.
course_id = None
else:
course_id = CourseKeyField.Empty

if course_id is not None:
queryset = queryset.filter(badge_class__course_id=course_id)
if self.request.query_params.get('slug'):
queryset = queryset.filter(
badge_class__slug=self.request.query_params['slug'],
badge_class__issuing_component=self.request.query_params.get('issuing_component', '')
)
return queryset
Loading