Commit 626f015a by Clinton Blackburn Committed by Clinton Blackburn

Added experiments app and API endpoint

parent 0dc5000e
"""
This app is used by the Rapid Experiments Team to hold data related to experiments.
WARNING: Do NOT use this app for anything long-term (or even short-tem) unless you are a member of the team. This app
is subject to change at any time without notice to those outside of the Rapid Experiments Team.
"""
from django.contrib import admin
from .models import ExperimentData
@admin.register(ExperimentData)
class ExperimentDataAdmin(admin.ModelAdmin):
list_display = ('user', 'experiment_id', 'key',)
list_filter = ('experiment_id',)
ordering = ('experiment_id', 'user', 'key',)
raw_id_fields = ('user',)
readonly_fields = ('created', 'modified',)
search_fields = ('experiment_id', 'user', 'key',)
import factory
from experiments.models import ExperimentData
from student.tests.factories import UserFactory
class ExperimentDataFactory(factory.DjangoModelFactory):
class Meta(object):
model = ExperimentData
user = factory.SubFactory(UserFactory)
experiment_id = factory.fuzzy.FuzzyInteger(0)
key = factory.Faker('word')
value = factory.Faker('word')
import django_filters
from experiments.models import ExperimentData
class ExperimentDataFilter(django_filters.FilterSet):
class Meta(object):
model = ExperimentData
fields = ['experiment_id', 'key', ]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ExperimentData',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('experiment_id', models.PositiveSmallIntegerField(verbose_name=b'Experiment ID', db_index=True)),
('key', models.CharField(max_length=255)),
('value', models.TextField()),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Experiment Data',
'verbose_name_plural': 'Experiment Data',
},
),
migrations.AlterUniqueTogether(
name='experimentdata',
unique_together=set([('user', 'experiment_id', 'key')]),
),
migrations.AlterIndexTogether(
name='experimentdata',
index_together=set([('user', 'experiment_id')]),
),
]
from django.conf import settings
from django.db import models
from django_extensions.db.models import TimeStampedModel
class ExperimentData(TimeStampedModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL)
experiment_id = models.PositiveSmallIntegerField(
null=False, blank=False, db_index=True, verbose_name='Experiment ID'
)
key = models.CharField(null=False, blank=False, max_length=255)
value = models.TextField()
class Meta(object):
index_together = (
('user', 'experiment_id'),
)
verbose_name = 'Experiment Data'
verbose_name_plural = 'Experiment Data'
unique_together = (
('user', 'experiment_id', 'key'),
)
from openedx.core.lib.api import permissions
class IsStaffOrOwner(permissions.IsStaffOrOwner):
"""
Permission that allows access to admin users or the owner of an object.
The owner is considered the User object represented by obj.user.
"""
def has_permission(self, request, view):
# The view will handle filtering for the current user
return True
from rest_framework import routers
from rest_framework.routers import DynamicDetailRoute, DynamicListRoute, Route
class DefaultRouter(routers.DefaultRouter):
routes = [
# List route.
Route(
url=r'^{prefix}{trailing_slash}$',
mapping={
'get': 'list',
'post': 'create',
# Allow PUT as create
'put': 'create_or_update',
},
name='{basename}-list',
initkwargs={'suffix': 'List'}
),
# Dynamically generated list routes.
# Generated using @list_route decorator
# on methods of the viewset.
DynamicListRoute(
url=r'^{prefix}/{methodname}{trailing_slash}$',
name='{basename}-{methodnamehyphen}',
initkwargs={}
),
# Detail route.
Route(
url=r'^{prefix}/{lookup}{trailing_slash}$',
mapping={
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy'
},
name='{basename}-detail',
initkwargs={'suffix': 'Instance'}
),
# Dynamically generated detail routes.
# Generated using @detail_route decorator on methods of the viewset.
DynamicDetailRoute(
url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$',
name='{basename}-{methodnamehyphen}',
initkwargs={}
),
]
from rest_framework import serializers
from .models import ExperimentData
class ExperimentDataSerializer(serializers.ModelSerializer):
user = serializers.SlugRelatedField(read_only=True, slug_field='username', default=serializers.CurrentUserDefault())
class Meta(object):
model = ExperimentData
fields = ('id', 'experiment_id', 'user', 'key', 'value', 'created', 'modified',)
read_only_fields = ('user',)
import urllib
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from experiments.factories import ExperimentDataFactory
from experiments.models import ExperimentData
from experiments.serializers import ExperimentDataSerializer
from student.tests.factories import UserFactory
class ExperimentDataViewSetTests(APITestCase):
def assert_data_created_for_user(self, user, method='post', status=201):
url = reverse('api_experiments:v0:data-list')
data = {
'experiment_id': 1,
'key': 'foo',
'value': 'bar',
}
self.client.login(username=user.username, password=UserFactory._DEFAULT_PASSWORD)
response = getattr(self.client, method)(url, data)
self.assertEqual(response.status_code, status)
# This will raise an exception if no data exists
ExperimentData.objects.get(user=user)
data['user'] = user.username
self.assertDictContainsSubset(data, response.data)
def test_list_permissions(self):
""" Users should only be able to list their own data. """
url = reverse('api_experiments:v0:data-list')
user = UserFactory()
response = self.client.get(url)
self.assertEqual(response.status_code, 401)
ExperimentDataFactory()
datum = ExperimentDataFactory(user=user)
self.client.login(username=user.username, password=UserFactory._DEFAULT_PASSWORD)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['results'], ExperimentDataSerializer([datum], many=True).data)
def test_list_filtering(self):
""" Users should be able to filter by the experiment_id and key fields. """
url = reverse('api_experiments:v0:data-list')
user = UserFactory()
self.client.login(username=user.username, password=UserFactory._DEFAULT_PASSWORD)
experiment_id = 1
ExperimentDataFactory()
ExperimentDataFactory(user=user)
data = ExperimentDataFactory.create_batch(3, user=user, experiment_id=experiment_id)
qs = urllib.urlencode({'experiment_id': experiment_id})
response = self.client.get('{url}?{qs}'.format(url=url, qs=qs))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['results'], ExperimentDataSerializer(data, many=True).data)
datum = data[0]
qs = urllib.urlencode({'key': datum.key})
response = self.client.get('{url}?{qs}'.format(url=url, qs=qs))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['results'], ExperimentDataSerializer([datum], many=True).data)
qs = urllib.urlencode({'experiment_id': experiment_id, 'key': datum.key})
response = self.client.get('{url}?{qs}'.format(url=url, qs=qs))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['results'], ExperimentDataSerializer([datum], many=True).data)
def test_read_permissions(self):
""" Users should only be allowed to read their own data. """
user = UserFactory()
datum = ExperimentDataFactory(user=user)
url = reverse('api_experiments:v0:data-detail', kwargs={'pk': datum.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 401)
self.client.login(username=user.username, password=UserFactory._DEFAULT_PASSWORD)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
other_user = UserFactory()
self.client.login(username=other_user.username, password=UserFactory._DEFAULT_PASSWORD)
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_create_permissions(self):
""" Users should only be allowed to create data for themselves. """
url = reverse('api_experiments:v0:data-list')
# Authentication is required
response = self.client.post(url, {})
self.assertEqual(response.status_code, 401)
self.assert_data_created_for_user(UserFactory())
self.assert_data_created_for_user(UserFactory())
def test_put_as_create(self):
""" Users should be able to use PUT to create new data. """
user = UserFactory()
self.assert_data_created_for_user(user, 'put')
# Subsequent requests should update the data
self.assert_data_created_for_user(user, 'put', 200)
def test_update_permissions(self):
""" Users should only be allowed to update their own data. """
user = UserFactory()
other_user = UserFactory()
datum = ExperimentDataFactory(user=user)
url = reverse('api_experiments:v0:data-detail', kwargs={'pk': datum.id})
data = {}
response = self.client.patch(url, data)
self.assertEqual(response.status_code, 401)
self.client.login(username=user.username, password=UserFactory._DEFAULT_PASSWORD)
response = self.client.patch(url, data)
self.assertEqual(response.status_code, 200)
self.client.login(username=other_user.username, password=UserFactory._DEFAULT_PASSWORD)
response = self.client.patch(url, data)
self.assertEqual(response.status_code, 404)
from django.conf.urls import include, url
from experiments import routers
from experiments.views import ExperimentDataViewSet
router = routers.DefaultRouter()
router.register(r'data', ExperimentDataViewSet, base_name='data')
urlpatterns = [
url(r'^v0/', include(router.urls, namespace='v0')),
]
from edx_rest_framework_extensions.authentication import JwtAuthentication
from rest_framework import permissions, viewsets
from rest_framework.filters import DjangoFilterBackend
from experiments import filters
from experiments.models import ExperimentData
from experiments.permissions import IsStaffOrOwner
from experiments.serializers import ExperimentDataSerializer
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
class ExperimentDataViewSet(viewsets.ModelViewSet):
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser,)
filter_backends = (DjangoFilterBackend,)
filter_class = filters.ExperimentDataFilter
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
queryset = ExperimentData.objects.all()
serializer_class = ExperimentDataSerializer
def filter_queryset(self, queryset):
queryset = queryset.filter(user=self.request.user)
return super(ExperimentDataViewSet, self).filter_queryset(queryset)
def create_or_update(self, request, *args, **kwargs):
# If we have a primary key, treat this as a regular update request
if self.kwargs.get('pk'):
return self.update(request, *args, **kwargs)
# If we only have data, check to see if an instance exists in the database. If so, update it.
# Otherwise, create a new instance.
experiment_id = request.data.get('experiment_id')
key = request.data.get('key')
if experiment_id and key:
try:
obj = self.get_queryset().get(user=self.request.user, experiment_id=experiment_id, key=key)
self.kwargs['pk'] = obj.pk
self.request.data['id'] = obj.pk
return self.update(request, *args, **kwargs)
except ExperimentData.DoesNotExist:
pass
return self.create(request, *args, **kwargs)
......@@ -2232,6 +2232,8 @@ INSTALLED_APPS = (
'openedx.features.course_experience',
'openedx.features.course_search',
'openedx.features.enterprise_support',
'experiments',
)
######################### CSRF #########################################
......
......@@ -108,10 +108,9 @@ urlpatterns = (
# URLs for API access management
url(r'^api-admin/', include('openedx.core.djangoapps.api_admin.urls', namespace='api_admin')),
)
urlpatterns += (
url(r'^dashboard/', include('learner_dashboard.urls')),
url(r'^api/experiments/', include('experiments.urls', namespace='api_experiments')),
)
# TODO: This needs to move to a separate urls.py once the student_account and
......
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