diff --git a/lms/djangoapps/badges/api/__init__.py b/lms/djangoapps/badges/api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py new file mode 100644 index 000000000000..65c2beaca818 --- /dev/null +++ b/lms/djangoapps/badges/api/serializers.py @@ -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') diff --git a/lms/djangoapps/badges/api/tests.py b/lms/djangoapps/badges/api/tests.py new file mode 100644 index 000000000000..7b4590250278 --- /dev/null +++ b/lms/djangoapps/badges/api/tests.py @@ -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): + """ + 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 diff --git a/lms/djangoapps/badges/api/urls.py b/lms/djangoapps/badges/api/urls.py new file mode 100644 index 000000000000..7857d6e5442a --- /dev/null +++ b/lms/djangoapps/badges/api/urls.py @@ -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'), +) diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py new file mode 100644 index 000000000000..ec1844253125 --- /dev/null +++ b/lms/djangoapps/badges/api/views.py @@ -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 diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index a1ad274c6cdc..19c8eecd445d 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -32,7 +32,7 @@ def validate_lowercase(string): """ Validates that a string is lowercase. """ - if not string == string.lower(): + if not string.islower(): raise ValidationError(_(u"This value must be all lowercase.")) diff --git a/lms/djangoapps/badges/tests/factories.py b/lms/djangoapps/badges/tests/factories.py index 4751ed413b1e..c625454a9b4a 100644 --- a/lms/djangoapps/badges/tests/factories.py +++ b/lms/djangoapps/badges/tests/factories.py @@ -54,7 +54,7 @@ class RandomBadgeClassFactory(BadgeClassFactory): """ Same as BadgeClassFactory, but randomize the slug. """ - slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random())) + slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random()).replace('.', '_')) class BadgeAssertionFactory(DjangoModelFactory): diff --git a/lms/urls.py b/lms/urls.py index 84f48ee65f5c..493700cfc67c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -127,6 +127,12 @@ url(r'^api/mobile/v0.5/', include('mobile_api.urls')), ) +if settings.FEATURES["ENABLE_OPENBADGES"]: + urlpatterns += ( + url(r'^api/badges/v1/', include('badges.api.urls')), + ) + + # if settings.FEATURES.get("MULTIPLE_ENROLLMENT_ROLES"): urlpatterns += ( # TODO Namespace these! diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index f6042d13f279..e0cd960685a9 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1,78 +1,52 @@ """Tests for the user API at the HTTP request level. """ import datetime -import base64 import json -import re from unittest import skipUnless, SkipTest import ddt import httpretty -from pytz import UTC import mock - from django.conf import settings -from django.core.urlresolvers import reverse -from django.core import mail from django.contrib.auth.models import User -from django.test import TestCase +from django.core import mail +from django.core.urlresolvers import reverse +from django.test.client import RequestFactory from django.test.testcases import TransactionTestCase from django.test.utils import override_settings -from django.test.client import RequestFactory - -from social.apps.django_app.default.models import UserSocialAuth - from opaque_keys.edx.locations import SlashSeparatedCourseKey +from pytz import UTC +from social.apps.django_app.default.models import UserSocialAuth from django_comment_common import models +from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY from student.tests.factories import UserFactory from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from third_party_auth.tests.utils import ( ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle ) -from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from ..accounts.api import get_account_settings +from xmodule.modulestore.tests.factories import CourseFactory from ..accounts import ( NAME_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH ) +from ..accounts.api import get_account_settings from ..models import UserOrgTag from ..tests.factories import UserPreferenceFactory from ..tests.test_constants import SORTED_COUNTRIES - -TEST_API_KEY = "test_api_key" USER_LIST_URI = "/user_api/v1/users/" USER_PREFERENCE_LIST_URI = "/user_api/v1/user_prefs/" ROLE_LIST_URI = "/user_api/v1/forum_roles/Moderator/users/" -@override_settings(EDX_API_KEY=TEST_API_KEY) -class ApiTestCase(TestCase): +class UserAPITestCase(ApiTestCase): """ - Parent test case for API workflow coverage + Parent test case for User API workflow coverage """ - LIST_URI = USER_LIST_URI - def basic_auth(self, username, password): - """ - Returns a dictionary containing the http auth header with encoded username+password - """ - return {'HTTP_AUTHORIZATION': 'Basic ' + base64.b64encode('%s:%s' % (username, password))} - - def request_with_auth(self, method, *args, **kwargs): - """Issue a get request to the given URI with the API key header""" - return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs) - - def get_json(self, *args, **kwargs): - """Make a request with the given args and return the parsed JSON repsonse""" - resp = self.request_with_auth("get", *args, **kwargs) - self.assertHttpOK(resp) - self.assertTrue(resp["Content-Type"].startswith("application/json")) - return json.loads(resp.content) - def get_uri_for_user(self, target_user): """Given a user object, get the URI for the corresponding resource""" users = self.get_json(USER_LIST_URI)["results"] @@ -89,20 +63,6 @@ def get_uri_for_pref(self, target_pref): return pref["url"] self.fail() - def assertAllowedMethods(self, uri, expected_methods): - """Assert that the allowed methods for the given URI match the expected list""" - resp = self.request_with_auth("options", uri) - self.assertHttpOK(resp) - allow_header = resp.get("Allow") - self.assertIsNotNone(allow_header) - allowed_methods = re.split('[^A-Z]+', allow_header) - self.assertItemsEqual(allowed_methods, expected_methods) - - def assertSelfReferential(self, obj): - """Assert that accessing the "url" entry in the given object returns the same object""" - copy = self.get_json(obj["url"]) - self.assertEqual(obj, copy) - def assertUserIsValid(self, user): """Assert that the given user result is valid""" self.assertItemsEqual(user.keys(), ["email", "id", "name", "username", "preferences", "url"]) @@ -120,37 +80,8 @@ def assertPrefIsValid(self, pref): self.assertSelfReferential(pref) self.assertUserIsValid(pref["user"]) - def assertHttpOK(self, response): - """Assert that the given response has the status code 200""" - self.assertEqual(response.status_code, 200) - - def assertHttpForbidden(self, response): - """Assert that the given response has the status code 403""" - self.assertEqual(response.status_code, 403) - - def assertHttpBadRequest(self, response): - """Assert that the given response has the status code 400""" - self.assertEqual(response.status_code, 400) - - def assertHttpMethodNotAllowed(self, response): - """Assert that the given response has the status code 405""" - self.assertEqual(response.status_code, 405) - - def assertAuthDisabled(self, method, uri): - """ - Assert that the Django rest framework does not interpret basic auth - headers for views exposed to anonymous users as an attempt to authenticate. - - """ - # Django rest framework interprets basic auth headers - # as an attempt to authenticate with the API. - # We don't want this for views available to anonymous users. - basic_auth_header = "Basic " + base64.b64encode('username:password') - response = getattr(self.client, method)(uri, HTTP_AUTHORIZATION=basic_auth_header) - self.assertNotEqual(response.status_code, 403) - -class EmptyUserTestCase(ApiTestCase): +class EmptyUserTestCase(UserAPITestCase): """ Test that the endpoint supports empty user result sets """ @@ -162,7 +93,7 @@ def test_get_list_empty(self): self.assertEqual(result["results"], []) -class EmptyRoleTestCase(ApiTestCase): +class EmptyRoleTestCase(UserAPITestCase): """Test that the endpoint supports empty result sets""" course_id = SlashSeparatedCourseKey.from_deprecated_string("org/course/run") LIST_URI = ROLE_LIST_URI + "?course_id=" + course_id.to_deprecated_string() @@ -176,7 +107,7 @@ def test_get_list_empty(self): self.assertEqual(result["results"], []) -class UserApiTestCase(ApiTestCase): +class UserApiTestCase(UserAPITestCase): """ Generalized test case class for specific implementations below """ @@ -606,7 +537,7 @@ def test_get_pagination(self): @ddt.ddt @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class LoginSessionViewTest(ApiTestCase): +class LoginSessionViewTest(UserAPITestCase): """Tests for the login end-points of the user API. """ USERNAME = "bob" @@ -772,7 +703,7 @@ def test_missing_login_params(self): @ddt.ddt @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class PasswordResetViewTest(ApiTestCase): +class PasswordResetViewTest(UserAPITestCase): """Tests of the user API's password reset endpoint. """ def setUp(self): @@ -828,7 +759,7 @@ def test_password_reset_form(self): @ddt.ddt @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase): +class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): """Tests for the registration end-points of the User API. """ maxDiff = None @@ -1799,7 +1730,7 @@ class TestGoogleRegistrationView( @ddt.ddt -class UpdateEmailOptInTestCase(ApiTestCase, SharedModuleStoreTestCase): +class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase): """Tests the UpdateEmailOptInPreference view. """ USERNAME = "steve" diff --git a/openedx/core/lib/api/test_utils.py b/openedx/core/lib/api/test_utils.py new file mode 100644 index 000000000000..9638a842a660 --- /dev/null +++ b/openedx/core/lib/api/test_utils.py @@ -0,0 +1,78 @@ +""" +Helpers for API tests. +""" +import base64 +import json + +import re +from django.test import TestCase +from django.test.utils import override_settings + +TEST_API_KEY = "test_api_key" + + +@override_settings(EDX_API_KEY=TEST_API_KEY) +class ApiTestCase(TestCase): + """ + Parent test case for API workflow coverage + """ + + def basic_auth(self, username, password): + """ + Returns a dictionary containing the http auth header with encoded username+password + """ + return {'HTTP_AUTHORIZATION': 'Basic ' + base64.b64encode('%s:%s' % (username, password))} + + def request_with_auth(self, method, *args, **kwargs): + """Issue a get request to the given URI with the API key header""" + return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs) + + def get_json(self, *args, **kwargs): + """Make a request with the given args and return the parsed JSON repsonse""" + resp = self.request_with_auth("get", *args, **kwargs) + self.assertHttpOK(resp) + self.assertTrue(resp["Content-Type"].startswith("application/json")) + return json.loads(resp.content) + + def assertAllowedMethods(self, uri, expected_methods): + """Assert that the allowed methods for the given URI match the expected list""" + resp = self.request_with_auth("options", uri) + self.assertHttpOK(resp) + allow_header = resp.get("Allow") + self.assertIsNotNone(allow_header) + allowed_methods = re.split('[^A-Z]+', allow_header) + self.assertItemsEqual(allowed_methods, expected_methods) + + def assertSelfReferential(self, obj): + """Assert that accessing the "url" entry in the given object returns the same object""" + copy = self.get_json(obj["url"]) + self.assertEqual(obj, copy) + + def assertHttpOK(self, response): + """Assert that the given response has the status code 200""" + self.assertEqual(response.status_code, 200) + + def assertHttpForbidden(self, response): + """Assert that the given response has the status code 403""" + self.assertEqual(response.status_code, 403) + + def assertHttpBadRequest(self, response): + """Assert that the given response has the status code 400""" + self.assertEqual(response.status_code, 400) + + def assertHttpMethodNotAllowed(self, response): + """Assert that the given response has the status code 405""" + self.assertEqual(response.status_code, 405) + + def assertAuthDisabled(self, method, uri): + """ + Assert that the Django rest framework does not interpret basic auth + headers for views exposed to anonymous users as an attempt to authenticate. + + """ + # Django rest framework interprets basic auth headers + # as an attempt to authenticate with the API. + # We don't want this for views available to anonymous users. + basic_auth_header = "Basic " + base64.b64encode('username:password') + response = getattr(self.client, method)(uri, HTTP_AUTHORIZATION=basic_auth_header) + self.assertNotEqual(response.status_code, 403)