-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Badges mobile api #10732
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Badges mobile api #10732
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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): | ||
| 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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: It would be ideal (from a DRY perspective) if these multiple |
||
| """ | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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
dummyis.There was a problem hiding this comment.
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 tougettext, I usually use double underscore instead.But yeah,
dummyalso works as an old-style convention.