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, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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. 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.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 = ( ...@@ -749,6 +749,10 @@ INSTALLED_APPS = (
'django_comment_client', 'django_comment_client',
'django_comment_common', 'django_comment_common',
'notes', 'notes',
# User API
'rest_framework',
'user_api',
) )
######################### MARKETING SITE ############################### ######################### MARKETING SITE ###############################
......
...@@ -256,6 +256,9 @@ if SEGMENT_IO_LMS_KEY: ...@@ -256,6 +256,9 @@ if SEGMENT_IO_LMS_KEY:
MITX_FEATURES['SEGMENT_IO_LMS'] = True MITX_FEATURES['SEGMENT_IO_LMS'] = True
########################## USER API ########################
EDX_API_KEY = ''
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
try: try:
......
...@@ -59,6 +59,8 @@ urlpatterns = ('', # nopep8 ...@@ -59,6 +59,8 @@ urlpatterns = ('', # nopep8
name='auth_password_reset_done'), name='auth_password_reset_done'),
url(r'^heartbeat$', include('heartbeat.urls')), url(r'^heartbeat$', include('heartbeat.urls')),
url(r'^user_api/', include('user_api.urls')),
) )
# University profiles only make sense in the default edX context # University profiles only make sense in the default edX context
......
...@@ -7,6 +7,7 @@ celery==3.0.19 ...@@ -7,6 +7,7 @@ celery==3.0.19
distribute>=0.6.28 distribute>=0.6.28
django-celery==3.0.17 django-celery==3.0.17
django-countries==1.5 django-countries==1.5
django-filter==0.6.0
django-followit==0.0.3 django-followit==0.0.3
django-keyedcache==1.4-6 django-keyedcache==1.4-6
django-kombu==0.9.4 django-kombu==0.9.4
...@@ -20,6 +21,7 @@ django-ses==0.4.1 ...@@ -20,6 +21,7 @@ django-ses==0.4.1
django-storages==1.1.5 django-storages==1.1.5
django-threaded-multihost==1.4-1 django-threaded-multihost==1.4-1
django-method-override==0.1.0 django-method-override==0.1.0
djangorestframework==2.3.5
django==1.4.5 django==1.4.5
feedparser==5.1.3 feedparser==5.1.3
fs==0.4.0 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