Commit 6a97ddf5 by Greg Price

Add an API to interact with users and preferences

The new API uses Django REST Framework. For now, it is designed specifically
to support the use cases required by the forum digest notifier (not yet built),
with a goal of making it more generally useful over time.
parent 9a61038c
......@@ -5,6 +5,11 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
LMS: Added user preferences (arbitrary user/key/value tuples, for which
which user/key is unique) and a REST API for reading users and
preferences. Access to the REST API is restricted by use of the
X-Edx-Api-Key HTTP header (which must match settings.EDX_API_KEY; if
the setting is not present, the API is disabled).
Common: Added *experimental* support for jsinput type.
......
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'UserPreference'
db.create_table('user_api_userpreference', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['auth.User'])),
('key', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('value', self.gf('django.db.models.fields.TextField')()),
))
db.send_create_signal('user_api', ['UserPreference'])
# Adding unique constraint on 'UserPreference', fields ['user', 'key']
db.create_unique('user_api_userpreference', ['user_id', 'key'])
def backwards(self, orm):
# Removing unique constraint on 'UserPreference', fields ['user', 'key']
db.delete_unique('user_api_userpreference', ['user_id', 'key'])
# Deleting model 'UserPreference'
db.delete_table('user_api_userpreference')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'user_api.userpreference': {
'Meta': {'unique_together': "(('user', 'key'),)", 'object_name': 'UserPreference'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}),
'value': ('django.db.models.fields.TextField', [], {})
}
}
complete_apps = ['user_api']
\ No newline at end of file
from django.contrib.auth.models import User
from django.db import models
class UserPreference(models.Model):
"""A user's preference, stored as generic text to be processed by client"""
user = models.ForeignKey(User, db_index=True, related_name="+")
key = models.CharField(max_length=255, db_index=True)
value = models.TextField()
class Meta:
unique_together = ("user", "key")
from django.contrib.auth.models import User
from rest_framework import serializers
from student.models import UserProfile
from user_api.models import UserPreference
class UserSerializer(serializers.HyperlinkedModelSerializer):
name = serializers.SerializerMethodField("get_name")
def get_name(self, user):
profile = UserProfile.objects.get(user=user)
return profile.name
class Meta:
model = User
# This list is the minimal set required by the notification service
fields = ("id", "email", "name")
read_only_fields = ("id", "email")
class UserPreferenceSerializer(serializers.HyperlinkedModelSerializer):
user = UserSerializer()
class Meta:
model = UserPreference
depth = 1
from factory import DjangoModelFactory
from user_api.models import UserPreference
class UserPreferenceFactory(DjangoModelFactory):
FACTORY_FOR = UserPreference
user = None
key = None
value = "default test value"
from django.db import IntegrityError
from django.test import TestCase
from student.tests.factories import UserFactory
from user_api.tests.factories import UserPreferenceFactory
class UserPreferenceModelTest(TestCase):
def test_duplicate_user_key(self):
user = UserFactory.create()
UserPreferenceFactory.create(user=user, key="testkey", value="first")
self.assertRaises(
IntegrityError,
UserPreferenceFactory.create,
user=user,
key="testkey",
value="second"
)
def test_arbitrary_values(self):
user = UserFactory.create()
UserPreferenceFactory.create(user=user, key="testkey0", value="")
UserPreferenceFactory.create(user=user, key="testkey1", value="This is some English text!")
UserPreferenceFactory.create(user=user, key="testkey2", value="{'some': 'json'}")
UserPreferenceFactory.create(
user=user,
key="testkey3",
value="\xe8\xbf\x99\xe6\x98\xaf\xe4\xb8\xad\xe5\x9b\xbd\xe6\x96\x87\xe5\xad\x97'"
)
from django.contrib.auth.models import User
from django.test import TestCase
from django.test.utils import override_settings
import json
import re
from student.tests.factories import UserFactory
from unittest import SkipTest
from user_api.models import UserPreference
from user_api.tests.factories import UserPreferenceFactory
TEST_API_KEY = "test_api_key"
USER_LIST_URI = "/user_api/v1/users/"
USER_PREFERENCE_LIST_URI = "/user_api/v1/user_prefs/"
@override_settings(EDX_API_KEY=TEST_API_KEY)
class UserApiTestCase(TestCase):
def setUp(self):
super(UserApiTestCase, self).setUp()
self.users = [
UserFactory.create(
email="test{0}@test.org".format(i),
profile__name="Test {0}".format(i)
)
for i in range(5)
]
self.prefs = [
UserPreferenceFactory.create(user=self.users[0], key="key0"),
UserPreferenceFactory.create(user=self.users[0], key="key1"),
UserPreferenceFactory.create(user=self.users[1], key="key0")
]
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"]
for user in users:
if user["id"] == target_user.id:
return user["url"]
self.fail()
def get_uri_for_pref(self, target_pref):
"""Given a user preference object, get the URI for the corresponding resource"""
prefs = self.get_json(USER_PREFERENCE_LIST_URI)["results"]
for pref in prefs:
if (pref["user"]["id"] == target_pref.user.id and pref["key"] == target_pref.key):
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", "url"])
self.assertSelfReferential(user)
def assertPrefIsValid(self, pref):
self.assertItemsEqual(pref.keys(), ["user", "key", "value", "url"])
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 assertHttpMethodNotAllowed(self, response):
"""Assert that the given response has the status code 405"""
self.assertEqual(response.status_code, 405)
class UserViewSetTest(UserApiTestCase):
LIST_URI = USER_LIST_URI
def setUp(self):
super(UserViewSetTest, self).setUp()
self.detail_uri = self.get_uri_for_user(self.users[0])
# List view tests
def test_options_list(self):
self.assertAllowedMethods(self.LIST_URI, ["OPTIONS", "GET", "HEAD"])
def test_post_list_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("post", self.LIST_URI))
def test_put_list_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("put", self.LIST_URI))
def test_patch_list_not_allowed(self):
raise SkipTest("Django 1.4's test client does not support patch")
def test_delete_list_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("delete", self.LIST_URI))
def test_list_unauthorized(self):
self.assertHttpForbidden(self.client.get(self.LIST_URI))
def test_get_list_empty(self):
User.objects.all().delete()
result = self.get_json(self.LIST_URI)
self.assertEqual(result["count"], 0)
self.assertIsNone(result["next"])
self.assertIsNone(result["previous"])
self.assertEqual(result["results"], [])
def test_get_list_nonempty(self):
result = self.get_json(self.LIST_URI)
self.assertEqual(result["count"], 5)
self.assertIsNone(result["next"])
self.assertIsNone(result["previous"])
users = result["results"]
self.assertEqual(len(users), 5)
for user in users:
self.assertUserIsValid(user)
def test_get_list_pagination(self):
first_page = self.get_json(self.LIST_URI, data={"page_size": 3})
self.assertEqual(first_page["count"], 5)
first_page_next_uri = first_page["next"]
self.assertIsNone(first_page["previous"])
first_page_users = first_page["results"]
self.assertEqual(len(first_page_users), 3)
second_page = self.get_json(first_page_next_uri)
self.assertEqual(second_page["count"], 5)
self.assertIsNone(second_page["next"])
second_page_prev_uri = second_page["previous"]
second_page_users = second_page["results"]
self.assertEqual(len(second_page_users), 2)
self.assertEqual(self.get_json(second_page_prev_uri), first_page)
for user in first_page_users + second_page_users:
self.assertUserIsValid(user)
all_user_uris = [user["url"] for user in first_page_users + second_page_users]
self.assertEqual(len(set(all_user_uris)), 5)
# Detail view tests
def test_options_detail(self):
self.assertAllowedMethods(self.detail_uri, ["OPTIONS", "GET", "HEAD"])
def test_post_detail_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("post", self.detail_uri))
def test_put_detail_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("put", self.detail_uri))
def test_patch_detail_not_allowed(self):
raise SkipTest("Django 1.4's test client does not support patch")
def test_delete_detail_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("delete", self.detail_uri))
def test_get_detail_unauthorized(self):
self.assertHttpForbidden(self.client.get(self.detail_uri))
def test_get_detail(self):
user = self.users[1]
uri = self.get_uri_for_user(user)
self.assertEqual(
self.get_json(uri),
{
"email": user.email,
"id": user.id,
"name": user.profile.name,
"url": uri,
}
)
class UserPreferenceViewSetTest(UserApiTestCase):
LIST_URI = USER_PREFERENCE_LIST_URI
def setUp(self):
super(UserPreferenceViewSetTest, self).setUp()
self.detail_uri = self.get_uri_for_pref(self.prefs[0])
# List view tests
def test_options_list(self):
self.assertAllowedMethods(self.LIST_URI, ["OPTIONS", "GET", "HEAD"])
def test_put_list_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("put", self.LIST_URI))
def test_patch_list_not_allowed(self):
raise SkipTest("Django 1.4's test client does not support patch")
def test_delete_list_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("delete", self.LIST_URI))
def test_list_unauthorized(self):
self.assertHttpForbidden(self.client.get(self.LIST_URI))
def test_get_list_empty(self):
UserPreference.objects.all().delete()
result = self.get_json(self.LIST_URI)
self.assertEqual(result["count"], 0)
self.assertIsNone(result["next"])
self.assertIsNone(result["previous"])
self.assertEqual(result["results"], [])
def test_get_list_nonempty(self):
result = self.get_json(self.LIST_URI)
self.assertEqual(result["count"], 3)
self.assertIsNone(result["next"])
self.assertIsNone(result["previous"])
prefs = result["results"]
self.assertEqual(len(prefs), 3)
for pref in prefs:
self.assertPrefIsValid(pref)
def test_get_list_filter_key_empty(self):
result = self.get_json(self.LIST_URI, data={"key": "non-existent"})
self.assertEqual(result["count"], 0)
self.assertEqual(result["results"], [])
def test_get_list_filter_key_nonempty(self):
result = self.get_json(self.LIST_URI, data={"key": "key0"})
self.assertEqual(result["count"], 2)
prefs = result["results"]
self.assertEqual(len(prefs), 2)
for pref in prefs:
self.assertPrefIsValid(pref)
self.assertEqual(pref["key"], "key0")
def test_get_list_pagination(self):
first_page = self.get_json(self.LIST_URI, data={"page_size": 2})
self.assertEqual(first_page["count"], 3)
first_page_next_uri = first_page["next"]
self.assertIsNone(first_page["previous"])
first_page_prefs = first_page["results"]
self.assertEqual(len(first_page_prefs), 2)
second_page = self.get_json(first_page_next_uri)
self.assertEqual(second_page["count"], 3)
self.assertIsNone(second_page["next"])
second_page_prev_uri = second_page["previous"]
second_page_prefs = second_page["results"]
self.assertEqual(len(second_page_prefs), 1)
self.assertEqual(self.get_json(second_page_prev_uri), first_page)
for pref in first_page_prefs + second_page_prefs:
self.assertPrefIsValid(pref)
all_pref_uris = [pref["url"] for pref in first_page_prefs + second_page_prefs]
self.assertEqual(len(set(all_pref_uris)), 3)
# Detail view tests
def test_options_detail(self):
self.assertAllowedMethods(self.detail_uri, ["OPTIONS", "GET", "HEAD"])
def test_post_detail_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("post", self.detail_uri))
def test_put_detail_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("put", self.detail_uri))
def test_patch_detail_not_allowed(self):
raise SkipTest("Django 1.4's test client does not support patch")
def test_delete_detail_not_allowed(self):
self.assertHttpMethodNotAllowed(self.request_with_auth("delete", self.detail_uri))
def test_detail_unauthorized(self):
self.assertHttpForbidden(self.client.get(self.detail_uri))
def test_get_detail(self):
pref = self.prefs[1]
uri = self.get_uri_for_pref(pref)
self.assertEqual(
self.get_json(uri),
{
"user": {
"email": pref.user.email,
"id": pref.user.id,
"name": pref.user.profile.name,
"url": self.get_uri_for_user(pref.user),
},
"key": pref.key,
"value": pref.value,
"url": uri,
}
)
from django.conf.urls import include, patterns, url
from rest_framework import routers
from user_api import views as user_api_views
user_api_router = routers.DefaultRouter()
user_api_router.register(r'users', user_api_views.UserViewSet)
user_api_router.register(r'user_prefs', user_api_views.UserPreferenceViewSet)
urlpatterns = patterns(
'',
url(r'^v1/', include(user_api_router.urls)),
)
from django.conf import settings
from django.contrib.auth.models import User
from rest_framework import filters
from rest_framework import permissions
from rest_framework import viewsets
from user_api.models import UserPreference
from user_api.serializers import UserSerializer, UserPreferenceSerializer
class ApiKeyHeaderPermission(permissions.BasePermission):
def has_permission(self, request, view):
"""
Check for permissions by matching the configured API key and header
settings.EDX_API_KEY must be set, and the X-Edx-Api-Key HTTP header must
be present in the request and match the setting.
"""
api_key = getattr(settings, "EDX_API_KEY", None)
return api_key is not None and request.META.get("HTTP_X_EDX_API_KEY") == api_key
class UserViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (ApiKeyHeaderPermission,)
queryset = User.objects.all()
serializer_class = UserSerializer
paginate_by = 10
paginate_by_param = "page_size"
class UserPreferenceViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (ApiKeyHeaderPermission,)
queryset = UserPreference.objects.all()
filter_backends = (filters.DjangoFilterBackend,)
filter_fields = ("key",)
serializer_class = UserPreferenceSerializer
paginate_by = 10
paginate_by_param = "page_size"
......@@ -749,6 +749,10 @@ INSTALLED_APPS = (
'django_comment_client',
'django_comment_common',
'notes',
# User API
'rest_framework',
'user_api',
)
######################### MARKETING SITE ###############################
......
......@@ -256,6 +256,9 @@ if SEGMENT_IO_LMS_KEY:
MITX_FEATURES['SEGMENT_IO_LMS'] = True
########################## USER API ########################
EDX_API_KEY = ''
#####################################################################
# Lastly, see if the developer has any local overrides.
try:
......
......@@ -59,6 +59,8 @@ urlpatterns = ('', # nopep8
name='auth_password_reset_done'),
url(r'^heartbeat$', include('heartbeat.urls')),
url(r'^user_api/', include('user_api.urls')),
)
# University profiles only make sense in the default edX context
......
......@@ -7,6 +7,7 @@ celery==3.0.19
distribute>=0.6.28
django-celery==3.0.17
django-countries==1.5
django-filter==0.6.0
django-followit==0.0.3
django-keyedcache==1.4-6
django-kombu==0.9.4
......@@ -20,6 +21,7 @@ django-ses==0.4.1
django-storages==1.1.5
django-threaded-multihost==1.4-1
django-method-override==0.1.0
djangorestframework==2.3.5
django==1.4.5
feedparser==5.1.3
fs==0.4.0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment