Commit 9d336c33 by Ben McMorran

TNL-1897 Implement Course Team API

parent 0db8bac7
......@@ -38,6 +38,8 @@ from track import contexts
from eventtracking import tracker
from importlib import import_module
from south.modelsinspector import add_introspection_rules
from opaque_keys.edx.locations import SlashSeparatedCourseKey
import lms.lib.comment_client as cc
......@@ -1750,6 +1752,33 @@ class EntranceExamConfiguration(models.Model):
return can_skip
class LanguageField(models.CharField):
"""Represents a language from the ISO 639-1 language set."""
def __init__(self, *args, **kwargs):
"""Creates a LanguageField.
Accepts all the same kwargs as a CharField, except for max_length and
choices. help_text defaults to a description of the ISO 639-1 set.
"""
kwargs.pop('max_length', None)
kwargs.pop('choices', None)
help_text = kwargs.pop(
'help_text',
_("The ISO 639-1 language code for this language."),
)
super(LanguageField, self).__init__(
max_length=16,
choices=settings.ALL_LANGUAGES,
help_text=help_text,
*args,
**kwargs
)
add_introspection_rules([], [r"^student\.models\.LanguageField"])
class LanguageProficiency(models.Model):
"""
Represents a user's language proficiency.
......
"""
Utilities for django models.
"""
import unicodedata
import re
from eventtracking import tracker
from django.conf import settings
from django.utils.encoding import force_unicode
from django.utils.safestring import mark_safe
from django_countries.fields import Country
......@@ -143,3 +148,47 @@ def _get_truncated_setting_value(value, max_length=None):
return value[0:max_length], True
else:
return value, False
# Taken from Django 1.8 source code because it's not supported in 1.4
def slugify(value):
"""Converts value into a string suitable for readable URLs.
Converts to ASCII. Converts spaces to hyphens. Removes characters that
aren't alphanumerics, underscores, or hyphens. Converts to lowercase.
Also strips leading and trailing whitespace.
Args:
value (string): String to slugify.
"""
value = force_unicode(value)
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
return mark_safe(re.sub(r'[-\s]+', '-', value))
def generate_unique_readable_id(name, queryset, lookup_field):
"""Generates a unique readable id from name by appending a numeric suffix.
Args:
name (string): Name to generate the id from. May include spaces.
queryset (QuerySet): QuerySet to check for uniqueness within.
lookup_field (string): Field name on the model that corresponds to the
unique identifier.
Returns:
string: generated unique identifier
"""
candidate = slugify(name)
conflicts = queryset.filter(**{lookup_field + '__startswith': candidate}).values_list(lookup_field, flat=True)
if conflicts and candidate in conflicts:
suffix = 2
while True:
new_id = candidate + '-' + str(suffix)
if new_id not in conflicts:
candidate = new_id
break
suffix += 1
return candidate
......@@ -303,9 +303,9 @@ class TeamsConfigurationTestCase(unittest.TestCase):
""" Make a sample topic dictionary. """
next_num = self.count.next()
topic_id = "topic_id_{}".format(next_num)
display_name = "Display Name {}".format(next_num)
name = "Name {}".format(next_num)
description = "Description {}".format(next_num)
return {"display_name": display_name, "description": description, "id": topic_id}
return {"name": name, "description": description, "id": topic_id}
def test_teams_enabled_new_course(self):
# Make sure we can detect when no teams exist.
......
"""Defines the URL routes for the Team API."""
from django.conf import settings
from django.conf.urls import patterns, url
from .views import (
TeamsListView,
TeamsDetailView,
TopicDetailView,
TopicListView
)
TEAM_ID_PATTERN = r'(?P<team_id>[a-z\d_-]+)'
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
TOPIC_ID_PATTERN = TEAM_ID_PATTERN.replace('team_id', 'topic_id')
urlpatterns = patterns(
'',
url(
r'^v0/teams$',
TeamsListView.as_view(),
name="teams_list"
),
url(
r'^v0/teams/' + TEAM_ID_PATTERN + '$',
TeamsDetailView.as_view(),
name="teams_detail"
),
url(
r'^v0/topics/$',
TopicListView.as_view(),
name="topics_list"
),
url(
r'^v0/topics/' + TOPIC_ID_PATTERN + ',' + settings.COURSE_ID_PATTERN + '$',
TopicDetailView.as_view(),
name="topics_detail"
)
)
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as 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 'CourseTeam'
db.create_table('teams_courseteam', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('team_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)),
('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
('topic_id', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, blank=True)),
('date_created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('description', self.gf('django.db.models.fields.CharField')(max_length=300)),
('country', self.gf('django_countries.fields.CountryField')(max_length=2, blank=True)),
('language', self.gf('student.models.LanguageField')(max_length=16, blank=True)),
))
db.send_create_signal('teams', ['CourseTeam'])
# Adding model 'CourseTeamMembership'
db.create_table('teams_courseteammembership', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('team', self.gf('django.db.models.fields.related.ForeignKey')(related_name='membership', to=orm['teams.CourseTeam'])),
('date_joined', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
))
db.send_create_signal('teams', ['CourseTeamMembership'])
# Adding unique constraint on 'CourseTeamMembership', fields ['user', 'team']
db.create_unique('teams_courseteammembership', ['user_id', 'team_id'])
def backwards(self, orm):
# Removing unique constraint on 'CourseTeamMembership', fields ['user', 'team']
db.delete_unique('teams_courseteammembership', ['user_id', 'team_id'])
# Deleting model 'CourseTeam'
db.delete_table('teams_courseteam')
# Deleting model 'CourseTeamMembership'
db.delete_table('teams_courseteammembership')
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'})
},
'teams.courseteam': {
'Meta': {'object_name': 'CourseTeam'},
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'description': ('django.db.models.fields.CharField', [], {'max_length': '300'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'language': ('student.models.LanguageField', [], {'max_length': '16', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'team_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
'topic_id': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'teams'", 'symmetrical': 'False', 'through': "orm['teams.CourseTeamMembership']", 'to': "orm['auth.User']"})
},
'teams.courseteammembership': {
'Meta': {'unique_together': "(('user', 'team'),)", 'object_name': 'CourseTeamMembership'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'team': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'membership'", 'to': "orm['teams.CourseTeam']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['teams']
\ No newline at end of file
"""Django models related to teams functionality."""
from django.contrib.auth.models import User
from django.db import models
from django.utils.translation import ugettext_lazy
from django_countries.fields import CountryField
from xmodule_django.models import CourseKeyField
from util.model_utils import generate_unique_readable_id
from student.models import LanguageField
class CourseTeam(models.Model):
"""This model represents team related info."""
team_id = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=255)
is_active = models.BooleanField(default=True)
course_id = CourseKeyField(max_length=255, db_index=True)
topic_id = models.CharField(max_length=255, db_index=True, blank=True)
date_created = models.DateTimeField(auto_now_add=True)
# last_activity is computed through a query
description = models.CharField(max_length=300)
country = CountryField(blank=True)
language = LanguageField(
blank=True,
help_text=ugettext_lazy("Optional language the team uses as ISO 639-1 code."),
)
users = models.ManyToManyField(User, db_index=True, related_name='teams', through='CourseTeamMembership')
@classmethod
def create(cls, name, course_id, description, topic_id=None, country=None, language=None):
"""Create a complete CourseTeam object.
Args:
name (str): The name of the team to be created.
course_id (str): The ID string of the course associated
with this team.
description (str): A description of the team.
topic_id (str): An optional identifier for the topic the
team formed around.
country (str, optional): An optional country where the team
is based, as ISO 3166-1 code.
language (str, optional): An optional language which the
team uses, as ISO 639-1 code.
"""
team_id = generate_unique_readable_id(name, cls.objects.all(), 'team_id')
course_team = cls(
team_id=team_id,
name=name,
course_id=course_id,
topic_id=topic_id if topic_id else '',
description=description,
country=country if country else '',
language=language if language else '',
)
return course_team
def add_user(self, user):
"""Adds the given user to the CourseTeam."""
CourseTeamMembership.objects.get_or_create(
user=user,
team=self
)
class CourseTeamMembership(models.Model):
"""This model represents the membership of a single user in a single team."""
class Meta(object):
"""Stores meta information for the model."""
unique_together = (('user', 'team'),)
user = models.ForeignKey(User)
team = models.ForeignKey(CourseTeam, related_name='membership')
date_joined = models.DateTimeField(auto_now_add=True)
"""Defines serializers used by the Team API."""
from django.contrib.auth.models import User
from rest_framework import serializers
from openedx.core.lib.api.serializers import CollapsedReferenceSerializer
from openedx.core.lib.api.fields import ExpandableField
from .models import CourseTeam, CourseTeamMembership
from openedx.core.djangoapps.user_api.serializers import UserSerializer
class UserMembershipSerializer(serializers.ModelSerializer):
"""Serializes CourseTeamMemberships with only user and date_joined
Used for listing team members.
"""
user = ExpandableField(
collapsed_serializer=CollapsedReferenceSerializer(
model_class=User,
id_source='username',
view_name='accounts_api',
read_only=True,
),
expanded_serializer=UserSerializer(),
)
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeamMembership
fields = ("user", "date_joined")
read_only_fields = ("date_joined",)
class CourseTeamSerializer(serializers.ModelSerializer):
"""Serializes a CourseTeam with membership information."""
id = serializers.CharField(source='team_id', read_only=True) # pylint: disable=invalid-name
membership = UserMembershipSerializer(many=True, read_only=True)
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeam
fields = (
"id",
"name",
"is_active",
"course_id",
"topic_id",
"date_created",
"description",
"country",
"language",
"membership",
)
read_only_fields = ("course_id", "date_created")
class CourseTeamCreationSerializer(serializers.ModelSerializer):
"""Deserializes a CourseTeam for creation."""
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeam
fields = (
"name",
"course_id",
"description",
"topic_id",
"country",
"language",
)
def restore_object(self, attrs, instance=None):
"""Restores a CourseTeam instance from the given attrs."""
return CourseTeam.create(
name=attrs.get("name", ''),
course_id=attrs.get("course_id"),
description=attrs.get("description", ''),
topic_id=attrs.get("topic_id", ''),
country=attrs.get("country", ''),
language=attrs.get("language", ''),
)
class MembershipSerializer(serializers.ModelSerializer):
"""Serializes CourseTeamMemberships with information about both teams and users."""
user = ExpandableField(
collapsed_serializer=CollapsedReferenceSerializer(
model_class=User,
id_source='username',
view_name='accounts_api',
read_only=True,
),
expanded_serializer=UserSerializer(read_only=True)
)
team = ExpandableField(
collapsed_serializer=CollapsedReferenceSerializer(
model_class=CourseTeam,
id_source='team_id',
view_name='teams_detail',
read_only=True,
),
expanded_serializer=CourseTeamSerializer(read_only=True)
)
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeamMembership
fields = ("user", "team", "date_joined")
read_only_fields = ("date_joined",)
class TopicSerializer(serializers.Serializer):
"""Serializes a topic."""
description = serializers.CharField()
name = serializers.CharField()
id = serializers.CharField() # pylint: disable=invalid-name
"""Factories for testing the Teams API."""
import factory
from factory.django import DjangoModelFactory
from ..models import CourseTeam
class CourseTeamFactory(DjangoModelFactory):
"""Factory for CourseTeams.
Note that team_id is not auto-generated from name when using the factory.
"""
FACTORY_FOR = CourseTeam
FACTORY_DJANGO_GET_OR_CREATE = ('team_id',)
team_id = factory.Sequence('team-{0}'.format)
name = "Awesome Team"
description = "A simple description"
"""
Tests for views.py
"""
# -*- coding: utf-8 -*-
"""Tests for the teams API at the HTTP request level."""
# pylint: disable=maybe-no-member
import json
import ddt
from django.core.urlresolvers import reverse
from nose.plugins.attrib import attr
from student.tests.factories import (
CourseEnrollmentFactory,
UserFactory,
)
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from rest_framework.test import APITestCase, APIClient
from courseware.tests.factories import StaffFactory
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment
from xmodule.modulestore.tests.factories import CourseFactory
from django.http import Http404
from django.core.urlresolvers import reverse
from rest_framework.test import APIClient
from .factories import CourseTeamFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@attr('shard_1')
class TestDashboard(ModuleStoreTestCase):
"""Tests for the Teams dashboard."""
test_password = "test"
def setUp(self):
......@@ -83,3 +88,438 @@ class TestDashboard(ModuleStoreTestCase):
bad_team_url = bad_team_url.replace(bad_org, "invalid/course/id")
response = self.client.get(bad_team_url)
self.assertEqual(404, response.status_code)
class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
"""Base class for Team API test cases."""
test_password = 'password'
def setUp(self):
super(TeamAPITestCase, self).setUp()
teams_configuration = {
'topics':
[
{
'id': 'topic_{}'.format(i),
'name': name,
'description': 'Description for topic {}.'.format(i)
} for i, name in enumerate([u'sólar power', 'Wind Power', 'Nuclear Power', 'Coal Power'])
]
}
self.topics_count = 4
self.test_course_1 = CourseFactory.create(
org='TestX',
course='TS101',
display_name='Test Course',
teams_configuration=teams_configuration
)
self.test_course_2 = CourseFactory.create(org='MIT', course='6.002x', display_name='Circuits')
self.users = {
'student_unenrolled': UserFactory.create(password=self.test_password),
'student_enrolled': UserFactory.create(password=self.test_password),
'staff': AdminFactory.create(password=self.test_password),
'course_staff': StaffFactory.create(course_key=self.test_course_1.id, password=self.test_password)
}
# 'solar team' is intentionally lower case to test case insensitivity in name ordering
self.test_team_1 = CourseTeamFactory.create(
name=u'sólar team',
course_id=self.test_course_1.id,
topic_id='renewable'
)
self.test_team_2 = CourseTeamFactory.create(name='Wind Team', course_id=self.test_course_1.id)
self.test_team_3 = CourseTeamFactory.create(name='Nuclear Team', course_id=self.test_course_1.id)
self.test_team_4 = CourseTeamFactory.create(name='Coal Team', course_id=self.test_course_1.id, is_active=False)
self.test_team_4 = CourseTeamFactory.create(name='Another Team', course_id=self.test_course_2.id)
self.test_team_1.add_user(self.users['student_enrolled'])
CourseEnrollment.enroll(
self.users['student_enrolled'], self.test_course_1.id, check_access=True
)
def login(self, user):
"""Given a user string, logs the given user in.
Used for testing with ddt, which does not have access to self in
decorators. If user is 'student_inactive', then an inactive user will
be both created and logged in.
"""
if user == 'student_inactive':
student_inactive = UserFactory.create(password=self.test_password)
self.client.login(username=student_inactive.username, password=self.test_password)
student_inactive.is_active = False
student_inactive.save()
else:
self.client.login(username=self.users[user].username, password=self.test_password)
def make_call(self, url, expected_status=200, method='get', data=None, content_type=None, **kwargs):
"""Makes a call to the Team API at the given url with method and data.
If a user is specified in kwargs, that user is first logged in.
"""
user = kwargs.pop('user', 'student_enrolled')
if user:
self.login(user)
func = getattr(self.client, method)
if content_type:
response = func(url, data=data, content_type=content_type)
else:
response = func(url, data=data)
self.assertEqual(expected_status, response.status_code)
if expected_status == 200:
return json.loads(response.content)
else:
return response
def get_teams_list(self, expected_status=200, data=None, no_course_id=False, **kwargs):
"""Gets the list of teams as the given user with data as query params. Verifies expected_status."""
data = data if data else {}
if 'course_id' not in data and not no_course_id:
data.update({'course_id': self.test_course_1.id})
return self.make_call(reverse('teams_list'), expected_status, 'get', data, **kwargs)
def build_team_data(self, name="Test team", course=None, description="Filler description", **kwargs):
"""Creates the payload for creating a team. kwargs can be used to specify additional fields."""
data = kwargs
course = course if course else self.test_course_1
data.update({
'name': name,
'course_id': str(course.id),
'description': description,
})
return data
def post_create_team(self, expected_status=200, data=None, **kwargs):
"""Posts data to the team creation endpoint. Verifies expected_status."""
return self.make_call(reverse('teams_list'), expected_status, 'post', data, **kwargs)
def get_team_detail(self, team_id, expected_status=200, **kwargs):
"""Gets detailed team information for team_id. Verifies expected_status."""
return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'get', **kwargs)
def patch_team_detail(self, team_id, expected_status, data=None, **kwargs):
"""Patches the team with team_id using data. Verifies expected_status."""
return self.make_call(
reverse('teams_detail', args=[team_id]),
expected_status,
'patch',
json.dumps(data) if data else None,
'application/merge-patch+json',
**kwargs
)
def get_topics_list(self, expected_status=200, data=None, **kwargs):
"""Gets the list of topics, passing data as query params. Verifies expected_status."""
return self.make_call(reverse('topics_list'), expected_status, 'get', data, **kwargs)
def get_topic_detail(self, topic_id, course_id, expected_status=200, data=None, **kwargs):
"""Gets a single topic, passing data as query params. Verifies expected_status."""
return self.make_call(
reverse('topics_detail', kwargs={'topic_id': topic_id, 'course_id': str(course_id)}),
expected_status,
'get',
data,
**kwargs
)
@ddt.ddt
class TestListTeamsAPI(TeamAPITestCase):
"""Test cases for the team listing API endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
teams = self.get_teams_list(user=user, expected_status=status)
if status == 200:
self.assertEqual(3, teams['count'])
def test_missing_course_id(self):
self.get_teams_list(400, no_course_id=True)
def verify_names(self, data, status, names=None, **kwargs):
"""Gets a team listing with data as query params, verifies status, and then verifies team names if specified."""
teams = self.get_teams_list(data=data, expected_status=status, **kwargs)
if names:
self.assertEqual(names, [team['name'] for team in teams['results']])
def test_filter_invalid_course_id(self):
self.verify_names({'course_id': 'foobar'}, 400)
def test_filter_course_id(self):
self.verify_names({'course_id': self.test_course_2.id}, 200, ['Another Team'], user='staff')
def test_filter_topic_id(self):
self.verify_names({'course_id': self.test_course_1.id, 'topic_id': 'renewable'}, 200, [u'sólar team'])
def test_filter_include_inactive(self):
self.verify_names({'include_inactive': True}, 200, ['Coal Team', 'Nuclear Team', u'sólar team', 'Wind Team'])
# Text search is not yet implemented, so this should return HTTP
# 400 for now
def test_filter_text_search(self):
self.verify_names({'text_search': 'foobar'}, 400)
@ddt.data(
(None, 200, ['Nuclear Team', u'sólar team', 'Wind Team']),
('name', 200, ['Nuclear Team', u'sólar team', 'Wind Team']),
('open_slots', 200, ['Wind Team', 'Nuclear Team', u'sólar team']),
('last_activity', 400, []),
)
@ddt.unpack
def test_order_by(self, field, status, names):
data = {'order_by': field} if field else {}
self.verify_names(data, status, names)
@ddt.data({'course_id': 'foobar/foobar/foobar'}, {'topic_id': 'foobar'})
def test_no_results(self, data):
self.get_teams_list(404, data)
def test_page_size(self):
result = self.get_teams_list(200, {'page_size': 2})
self.assertEquals(2, result['num_pages'])
def test_page(self):
result = self.get_teams_list(200, {'page_size': 1, 'page': 3})
self.assertEquals(3, result['num_pages'])
self.assertIsNone(result['next'])
self.assertIsNotNone(result['previous'])
@ddt.ddt
class TestCreateTeamAPI(TeamAPITestCase):
"""Test cases for the team creation endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
('course_staff', 200)
)
@ddt.unpack
def test_access(self, user, status):
team = self.post_create_team(status, self.build_team_data(name="New Team"), user=user)
if status == 200:
self.assertEqual(team['id'], 'new-team')
teams = self.get_teams_list(user=user)
self.assertIn("New Team", [team['name'] for team in teams['results']])
def test_naming(self):
new_teams = [
self.post_create_team(data=self.build_team_data(name=name))
for name in ["The Best Team", "The Best Team", "The Best Team", "The Best Team 2"]
]
self.assertEquals(
[team['id'] for team in new_teams],
['the-best-team', 'the-best-team-2', 'the-best-team-3', 'the-best-team-2-2']
)
@ddt.data((400, {
'name': 'Bad Course Id',
'course_id': 'foobar',
'description': "Filler Description"
}), (404, {
'name': "Non-existent course id",
'course_id': 'foobar/foobar/foobar',
'description': "Filler Description"
}))
@ddt.unpack
def test_bad_course_data(self, status, data):
self.post_create_team(status, data)
def test_missing_name(self):
self.post_create_team(400, {
'course_id': str(self.test_course_1.id),
'description': "foobar"
})
@ddt.data({'description': ''}, {'name': 'x' * 1000}, {'name': ''})
def test_bad_fields(self, kwargs):
self.post_create_team(400, self.build_team_data(**kwargs))
def test_full(self):
team = self.post_create_team(data=self.build_team_data(
name="Fully specified team",
course=self.test_course_1,
description="Another fantastic team",
topic_id='great-topic',
country='CA',
language='fr'
))
# Remove date_created because it changes between test runs
del team['date_created']
self.assertEquals(team, {
'name': 'Fully specified team',
'language': 'fr',
'country': 'CA',
'is_active': True,
'membership': [],
'topic_id': 'great-topic',
'course_id': str(self.test_course_1.id),
'id': 'fully-specified-team',
'description': 'Another fantastic team'
})
@ddt.ddt
class TestDetailTeamAPI(TeamAPITestCase):
"""Test cases for the team detail endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
team = self.get_team_detail(self.test_team_1.team_id, status, user=user)
if status == 200:
self.assertEquals(team['description'], self.test_team_1.description)
def test_does_not_exist(self):
self.get_team_detail('foobar', 404)
@ddt.ddt
class TestUpdateTeamAPI(TeamAPITestCase):
"""Test cases for the team update endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
('student_unenrolled', 403),
('student_enrolled', 403),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
team = self.patch_team_detail(self.test_team_1.team_id, status, {'name': 'foo'}, user=user)
if status == 200:
self.assertEquals(team['name'], 'foo')
@ddt.data(
(None, 403),
('student_inactive', 403),
('student_unenrolled', 404),
('student_enrolled', 404),
('staff', 404),
('course_staff', 404),
)
@ddt.unpack
def test_access_bad_id(self, user, status):
self.patch_team_detail("foobar", status, {'name': 'foo'}, user=user)
@ddt.data(
('id', 'foobar'),
('description', ''),
('country', 'foobar'),
('language', 'foobar')
)
@ddt.unpack
def test_bad_requests(self, key, value):
self.patch_team_detail(self.test_team_1.team_id, 400, {key: value}, user='staff')
@ddt.data(('country', 'US'), ('language', 'en'), ('foo', 'bar'))
@ddt.unpack
def test_good_requests(self, key, value):
self.patch_team_detail(self.test_team_1.team_id, 200, {key: value}, user='staff')
def test_does_not_exist(self):
self.patch_team_detail('foobar', 404, user='staff')
@ddt.ddt
class TestListTopicsAPI(TeamAPITestCase):
"""Test cases for the topic listing endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
topics = self.get_topics_list(status, {'course_id': self.test_course_1.id}, user=user)
if status == 200:
self.assertEqual(topics['count'], self.topics_count)
@ddt.data('A+BOGUS+COURSE', 'A/BOGUS/COURSE')
def test_invalid_course_key(self, course_id):
self.get_topics_list(404, {'course_id': course_id})
def test_without_course_id(self):
self.get_topics_list(400)
@ddt.data(
(None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']),
('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']),
('foobar', 400, []),
)
@ddt.unpack
def test_order_by(self, field, status, names):
data = {'course_id': self.test_course_1.id}
if field:
data['order_by'] = field
topics = self.get_topics_list(status, data)
if status == 200:
self.assertEqual(names, [topic['name'] for topic in topics['results']])
def test_pagination(self):
response = self.get_topics_list(data={
'course_id': self.test_course_1.id,
'page_size': 2,
})
self.assertEqual(2, len(response['results']))
self.assertIn('next', response)
self.assertIn('previous', response)
self.assertIsNone(response['previous'])
self.assertIsNotNone(response['next'])
@ddt.ddt
class TestDetailTopicAPI(TeamAPITestCase):
"""Test cases for the topic detail endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
topic = self.get_topic_detail('topic_0', self.test_course_1.id, status, user=user)
if status == 200:
for field in ('id', 'name', 'description'):
self.assertIn(field, topic)
@ddt.data('A+BOGUS+COURSE', 'A/BOGUS/COURSE')
def test_invalid_course_id(self, course_id):
self.get_topic_detail('topic_0', course_id, 404)
def test_invalid_topic_id(self):
self.get_topic_detail('foobar', self.test_course_1.id, 404)
"""
URLs for teams.
"""
"""Defines the URL routes for this app."""
from django.conf.urls import patterns, url
from teams.views import TeamsDashboardView
from .views import TeamsDashboardView
urlpatterns = patterns(
"teams.views",
url(r"^/$", TeamsDashboardView.as_view(), name="teams_dashboard"),
'teams.views',
url(r"^/$", TeamsDashboardView.as_view(), name="teams_dashboard")
)
"""
View methods for the course team feature.
"""
"""HTTP endpoints for the Teams API."""
from django.shortcuts import render_to_response
from opaque_keys.edx.keys import CourseKey
from courseware.courses import get_course_with_access, has_access
from django.http import Http404
from django.conf import settings
from django.views.generic.base import View
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.authentication import (
SessionAuthentication,
OAuth2Authentication
)
from rest_framework import status
from rest_framework import permissions
from django.db.models import Count
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop
from student.models import CourseEnrollment
from student.roles import CourseStaffRole
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.permissions import IsStaffOrReadOnly
from openedx.core.lib.api.view_utils import RetrievePatchAPIView, add_serializer_errors
from openedx.core.lib.api.serializers import PaginationSerializer
from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from .models import CourseTeam
from .serializers import CourseTeamSerializer, CourseTeamCreationSerializer, TopicSerializer
class TeamsDashboardView(View):
......@@ -42,3 +68,504 @@ def is_feature_enabled(course):
Returns True if the teams feature is enabled.
"""
return settings.FEATURES.get('ENABLE_TEAMS', False) and course.teams_enabled
def has_team_api_access(user, course_key):
"""Returns True if the user has access to the Team API for the course
given by `course_key`. The user must either be enrolled in the course,
be course staff, or be global staff.
Args:
user (User): The user to check access for.
course_key (CourseKey): The key to the course which we are checking access to.
Returns:
bool: True if the user has access, False otherwise.
"""
return (CourseEnrollment.is_enrolled(user, course_key) or
CourseStaffRole(course_key).has_user(user) or
user.is_staff)
class TeamsListView(GenericAPIView):
"""
**Use Cases**
Get or create a course team.
**Example Requests**:
GET /api/team/v0/teams
POST /api/team/v0/teams
**Query Parameters for GET**
* course_id: Filters the result to teams belonging to the given
course. Required.
* topic_id: Filters the result to teams associated with the given
topic.
* text_search: Currently not supported.
* order_by: Must be one of the following:
* name: Orders results by case insensitive team name (default).
* open_slots: Orders results by most open slots.
* last_activity: Currently not supported.
* page_size: Number of results to return per page.
* page: Page number to retrieve.
* include_inactive: If true, inactive teams will be returned. The
default is to not include inactive teams.
**Response Values for GET**
If the user is logged in and enrolled, the response contains:
* count: The total number of teams matching the request.
* next: The URL to the next page of results, or null if this is the
last page.
* previous: The URL to the previous page of results, or null if this
is the first page.
* num_pages: The total number of pages in the result.
* results: A list of the teams matching the request.
* id: The team's unique identifier.
* name: The name of the team.
* is_active: True if the team is currently active. If false, the
team is considered "soft deleted" and will not be included by
default in results.
* course_id: The identifier for the course this team belongs to.
* topic_id: Optionally specifies which topic the team is associated
with.
* date_created: Date and time when the team was created.
* description: A description of the team.
* country: Optionally specifies which country the team is
associated with.
* language: Optionally specifies which language the team is
associated with.
* membership: A list of the users that are members of the team.
See membership endpoint for more detail.
For all text fields, clients rendering the values should take care
to HTML escape them to avoid script injections, as the data is
stored exactly as specified. The intention is that plain text is
supported, not HTML.
If the user is not logged in and enrolled in the course specified by
course_id or is not course or global staff, a 403 error is returned.
If the specified course_id is not valid or the user attempts to
use an unsupported query parameter, a 400 error is returned.
If the response does not exist, a 404 error is returned. For
example, the course_id may not reference a real course or the page
number may be beyond the last page.
**Response Values for POST**
Any logged in user who has verified their email address can create
a team. The format mirrors that of a GET for an individual team,
but does not include the id, is_active, date_created, or membership
fields. id is automatically computed based on name.
If the user is not logged in, is not enrolled in the course, or is
not course or global staff, a 403 error is returned.
If the course_id is not valid or extra fields are included in the
request, a 400 error is returned.
If the specified course does not exist, a 404 error is returned.
"""
# SessionAuthentication must come first to return a 403 for unauthenticated users
authentication_classes = (SessionAuthentication, OAuth2Authentication)
permission_classes = (permissions.IsAuthenticated,)
paginate_by = 10
paginate_by_param = 'page_size'
pagination_serializer_class = PaginationSerializer
serializer_class = CourseTeamSerializer
def get_serializer_context(self):
"""Adds expand information from query parameters to the serializer context to support expandable fields."""
result = super(TeamsListView, self).get_serializer_context()
result['expand'] = [x for x in self.request.QUERY_PARAMS.get('expand', '').split(',') if x]
return result
def get(self, request):
"""GET /api/team/v0/teams/"""
result_filter = {
'is_active': True
}
if 'course_id' in request.QUERY_PARAMS:
course_id_string = request.QUERY_PARAMS['course_id']
try:
course_key = CourseKey.from_string(course_id_string)
# Ensure the course exists
if not modulestore().has_course(course_key):
return Response(status=status.HTTP_404_NOT_FOUND)
result_filter.update({'course_id': course_key})
except InvalidKeyError:
error_message = ugettext_noop("The supplied course id {course_id} is not valid.").format(
course_id=course_id_string
)
return Response({
'developer_message': error_message,
'user_message': _(error_message) # pylint: disable=translation-of-non-string
}, status=status.HTTP_400_BAD_REQUEST)
if not has_team_api_access(request.user, course_key):
return Response(status=status.HTTP_403_FORBIDDEN)
else:
error_message = ugettext_noop('course_id must be provided')
return Response({
'developer_message': error_message,
'user_message': _(error_message), # pylint: disable=translation-of-non-string
}, status=status.HTTP_400_BAD_REQUEST)
if 'topic_id' in request.QUERY_PARAMS:
result_filter.update({'topic_id': request.QUERY_PARAMS['topic_id']})
if 'include_inactive' in request.QUERY_PARAMS and request.QUERY_PARAMS['include_inactive'].lower() == 'true':
del result_filter['is_active']
if 'text_search' in request.QUERY_PARAMS:
error_message = ugettext_noop('text_search is not yet supported')
return Response({
'developer_message': error_message,
'user_message': _(error_message), # pylint: disable=translation-of-non-string
}, status=status.HTTP_400_BAD_REQUEST)
queryset = CourseTeam.objects.filter(**result_filter)
order_by_input = request.QUERY_PARAMS.get('order_by', 'name')
if order_by_input == 'name':
queryset = queryset.extra(select={'lower_name': "lower(name)"})
order_by_field = 'lower_name'
elif order_by_input == 'open_slots':
queryset = queryset.annotate(team_size=Count('users'))
order_by_field = 'team_size'
elif order_by_input == 'last_activity':
return Response({
'developer_message': "last_activity is not yet supported",
'user_message': _("The last_activity parameter is not yet supported."),
}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.order_by(order_by_field)
if not queryset:
return Response(status=status.HTTP_404_NOT_FOUND)
page = self.paginate_queryset(queryset)
serializer = self.get_pagination_serializer(page)
return Response(serializer.data) # pylint: disable=maybe-no-member
def post(self, request):
"""POST /api/team/v0/teams/"""
field_errors = {}
course_key = None
course_id = request.DATA.get('course_id')
try:
course_key = CourseKey.from_string(course_id)
# Ensure the course exists
if not modulestore().has_course(course_key):
return Response(status=status.HTTP_404_NOT_FOUND)
except InvalidKeyError:
field_errors['course_id'] = {
'developer_message': "course_id {} is not valid.".format(course_id),
'user_message': _("The supplied course_id {} is not valid.").format(course_id),
}
if course_key and not has_team_api_access(request.user, course_key):
return Response(status=status.HTTP_403_FORBIDDEN)
data = request.DATA
data['course_id'] = course_key
serializer = CourseTeamCreationSerializer(data=data)
add_serializer_errors(serializer, data, field_errors)
if field_errors:
return Response({
'field_errors': field_errors,
}, status=status.HTTP_400_BAD_REQUEST)
else:
team = serializer.save()
return Response(CourseTeamSerializer(team).data)
class TeamsDetailView(RetrievePatchAPIView):
"""
**Use Cases**
Get or update a course team's information. Updates are supported
only through merge patch.
**Example Requests**:
GET /api/team/v0/teams/{team_id}}
PATCH /api/team/v0/teams/{team_id} "application/merge-patch+json"
**Response Values for GET**
If the user is logged in, the response contains the following fields:
* id: The team's unique identifier.
* name: The name of the team.
* is_active: True if the team is currently active. If false, the team
is considered "soft deleted" and will not be included by default in
results.
* course_id: The identifier for the course this team belongs to.
* topic_id: Optionally specifies which topic the team is
associated with.
* date_created: Date and time when the team was created.
* description: A description of the team.
* country: Optionally specifies which country the team is
associated with.
* language: Optionally specifies which language the team is
associated with.
* membership: A list of the users that are members of the team. See
membership endpoint for more detail.
For all text fields, clients rendering the values should take care
to HTML escape them to avoid script injections, as the data is
stored exactly as specified. The intention is that plain text is
supported, not HTML.
If the user is not logged in or is not course or global staff, a 403
error is returned.
If the specified team does not exist, a 404 error is returned.
**Response Values for PATCH**
Only staff can patch teams.
If the user is anonymous or inactive, a 403 is returned.
If the user is logged in and the team does not exist, a 404 is returned.
If the user is not course or global staff and the team does exist,
a 403 is returned.
If "application/merge-patch+json" is not the specified content type,
a 415 error is returned.
If the update could not be completed due to validation errors, this
method returns a 400 error with all error messages in the
"field_errors" field of the returned JSON.
"""
class IsEnrolledOrIsStaff(permissions.BasePermission):
"""Permission that checks to see if the user is enrolled in the course or is staff."""
def has_object_permission(self, request, view, obj):
"""Returns true if the user is enrolled or is staff."""
return has_team_api_access(request.user, obj.course_id)
authentication_classes = (SessionAuthentication, OAuth2Authentication)
permission_classes = (permissions.IsAuthenticated, IsStaffOrReadOnly, IsEnrolledOrIsStaff,)
lookup_field = 'team_id'
serializer_class = CourseTeamSerializer
parser_classes = (MergePatchParser,)
def get_queryset(self):
"""Returns the queryset used to access the given team."""
return CourseTeam.objects.all()
class TopicListView(GenericAPIView):
"""
**Use Cases**
Retrieve a list of topics associated with a single course.
**Example Requests**
GET /api/team/v0/topics/?course_id={course_id}
**Query Parameters for GET**
* course_id: Filters the result to topics belonging to the given
course (required).
* order_by: Orders the results. Currently only 'name' is supported,
and is also the default value.
* page_size: Number of results to return per page.
* page: Page number to retrieve.
**Response Values for GET**
If the course_id is not given or an unsupported value is passed for
order_by, returns a 400 error.
If the user is not logged in, is not enrolled in the course, or is
not course or global staff, returns a 403 error.
If the course does not exist, returns a 404 error.
Otherwise, a 200 response is returned containing the following
fields:
* count: The total number of topics matching the request.
* next: The URL to the next page of results, or null if this is the
last page.
* previous: The URL to the previous page of results, or null if this
is the first page.
* num_pages: The total number of pages in the result.
* results: A list of the topics matching the request.
* id: The topic's unique identifier.
* name: The name of the topic.
* description: A description of the topic.
"""
authentication_classes = (SessionAuthentication, OAuth2Authentication)
permission_classes = (permissions.IsAuthenticated,)
paginate_by = 10
paginate_by_param = 'page_size'
pagination_serializer_class = PaginationSerializer
serializer_class = TopicSerializer
def get(self, request):
"""GET /api/team/v0/topics/?course_id={course_id}"""
course_id_string = request.QUERY_PARAMS.get('course_id', None)
if course_id_string is None:
return Response({
'field_errors': {
'course_id': {
'developer_message': "course_id {} is not valid.".format(course_id_string),
'user_message': _('The supplied course_id {} is not valid.').format(course_id_string)
}
}
}, status=status.HTTP_400_BAD_REQUEST)
try:
course_id = CourseKey.from_string(course_id_string)
except InvalidKeyError:
return Response(status=status.HTTP_404_NOT_FOUND)
# Ensure the course exists
course_module = modulestore().get_course(course_id)
if course_module is None: # course is None if not found
return Response(status=status.HTTP_404_NOT_FOUND)
if not has_team_api_access(request.user, course_id):
return Response(status=status.HTTP_403_FORBIDDEN)
topics = course_module.teams_topics
ordering = request.QUERY_PARAMS.get('order_by', 'name')
if ordering == 'name':
topics = sorted(topics, key=lambda t: t['name'].lower())
else:
return Response({
'developer_message': "unsupported order_by value {}".format(ordering),
'user_message': _(u"The ordering {} is not supported").format(ordering),
}, status=status.HTTP_400_BAD_REQUEST)
page = self.paginate_queryset(topics)
serializer = self.get_pagination_serializer(page)
return Response(serializer.data) # pylint: disable=maybe-no-member
class TopicDetailView(APIView):
"""
**Use Cases**
Retrieve a single topic from a course.
**Example Requests**
GET /api/team/v0/topics/{topic_id},{course_id}
**Query Parameters for GET**
* topic_id: The ID of the topic to retrieve (required).
* course_id: The ID of the course to retrieve the topic from
(required).
**Response Values for GET**
If the topic_id course_id are not given or an unsupported value is
passed for order_by, returns a 400 error.
If the user is not logged in, is not enrolled in the course, or is
not course or global staff, returns a 403 error.
If the course does not exist, returns a 404 error.
Otherwise, a 200 response is returned containing the following fields:
* id: The topic's unique identifier.
* name: The name of the topic.
* description: A description of the topic.
"""
authentication_classes = (SessionAuthentication, OAuth2Authentication)
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, topic_id, course_id):
"""GET /api/team/v0/topics/{topic_id},{course_id}/"""
try:
course_id = CourseKey.from_string(course_id)
except InvalidKeyError:
return Response(status=status.HTTP_404_NOT_FOUND)
# Ensure the course exists
course_module = modulestore().get_course(course_id)
if course_module is None:
return Response(status=status.HTTP_404_NOT_FOUND)
if not has_team_api_access(request.user, course_id):
return Response(status=status.HTTP_403_FORBIDDEN)
topics = [t for t in course_module.teams_topics if t['id'] == topic_id]
if len(topics) == 0:
return Response(status=status.HTTP_404_NOT_FOUND)
serializer = TopicSerializer(topics[0])
return Response(serializer.data)
......@@ -1792,6 +1792,9 @@ INSTALLED_APPS = (
'rest_framework',
'openedx.core.djangoapps.user_api',
# Team API
'teams',
# Shopping cart
'shoppingcart',
......
......@@ -431,6 +431,7 @@ if settings.COURSEWARE_ENABLED:
if settings.FEATURES["ENABLE_TEAMS"]:
# Teams endpoints
urlpatterns += (
url(r'^api/team/', include('teams.api_urls')),
url(r'^courses/{}/teams'.format(settings.COURSE_ID_PATTERN), include('teams.urls'), name="teams_endpoints"),
)
......
......@@ -10,6 +10,8 @@ from student.models import User, UserProfile, Registration
from student import views as student_views
from util.model_utils import emit_setting_changed_event
from openedx.core.lib.api.view_utils import add_serializer_errors
from ..errors import (
AccountUpdateError, AccountValidationError, AccountUsernameInvalid, AccountPasswordInvalid,
AccountEmailInvalid, AccountUserAlreadyExists,
......@@ -170,7 +172,7 @@ def update_account_settings(requesting_user, update, username=None):
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile, data=update)
for serializer in user_serializer, legacy_profile_serializer:
field_errors = _add_serializer_errors(update, serializer, field_errors)
field_errors = add_serializer_errors(serializer, update, field_errors)
# If the user asked to change email, validate it.
if changing_email:
......@@ -250,27 +252,6 @@ def _get_user_and_profile(username):
return existing_user, existing_user_profile
def _add_serializer_errors(update, serializer, field_errors):
"""
Helper method that adds any validation errors that are present in the serializer to
the supplied field_errors dict.
"""
if not serializer.is_valid():
errors = serializer.errors
for key, error in errors.iteritems():
field_value = update[key]
field_errors[key] = {
"developer_message": u"Value '{field_value}' is not valid for field '{field_name}': {error}".format(
field_value=field_value, field_name=key, error=error
),
"user_message": _(u"This value is invalid.").format(
field_value=field_value, field_name=key
),
}
return field_errors
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError])
@transaction.commit_on_success
def create_account(username, password, email):
......
"""Fields useful for edX API implementations."""
from rest_framework.serializers import Field
class ExpandableField(Field):
"""Field that can dynamically use a more detailed serializer based on a user-provided "expand" parameter."""
def __init__(self, **kwargs):
"""Sets up the ExpandableField with the collapsed and expanded versions of the serializer."""
assert 'collapsed_serializer' in kwargs and 'expanded_serializer' in kwargs
self.collapsed = kwargs.pop('collapsed_serializer')
self.expanded = kwargs.pop('expanded_serializer')
super(ExpandableField, self).__init__(**kwargs)
def field_to_native(self, obj, field_name):
"""Converts obj to a native representation, using the expanded serializer if the context requires it."""
if 'expand' in self.context and field_name in self.context['expand']:
self.expanded.initialize(self, field_name)
return self.expanded.field_to_native(obj, field_name)
else:
self.collapsed.initialize(self, field_name)
return self.collapsed.field_to_native(obj, field_name)
......@@ -2,6 +2,8 @@ from django.conf import settings
from rest_framework import permissions
from django.http import Http404
from student.roles import CourseStaffRole
class ApiKeyHeaderPermission(permissions.BasePermission):
def has_permission(self, request, view):
......@@ -74,3 +76,13 @@ class IsUserInUrlOrStaff(IsUserInUrl):
return True
return super(IsUserInUrlOrStaff, self).has_permission(request, view)
class IsStaffOrReadOnly(permissions.BasePermission):
"""Permission that checks to see if the user is global or course
staff, permitting only read-only access if they are not.
"""
def has_object_permission(self, request, view, obj):
return (request.user.is_staff or
CourseStaffRole(obj.course_id).has_user(request.user) or
request.method in permissions.SAFE_METHODS)
......@@ -6,3 +6,40 @@ class PaginationSerializer(pagination.PaginationSerializer):
Custom PaginationSerializer to include num_pages field
"""
num_pages = serializers.Field(source='paginator.num_pages')
class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
"""Serializes arbitrary models in a collapsed format, with just an id and url."""
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
url = serializers.HyperlinkedIdentityField(view_name='')
def __init__(self, model_class, view_name, id_source='id', lookup_field=None, *args, **kwargs):
"""Configures the serializer.
Args:
model_class (class): Model class to serialize.
view_name (string): Name of the Django view used to lookup the
model.
id_source (string): Optional name of the id field on the model.
Defaults to 'id'.
lookup_field (string): Optional name of the model field used to
lookup the model in the view. Defaults to the value of
id_source.
"""
if not lookup_field:
lookup_field = id_source
self.Meta.model = model_class
super(CollapsedReferenceSerializer, self).__init__(*args, **kwargs)
self.fields['id'].source = id_source
self.fields['url'].view_name = view_name
self.fields['url'].lookup_field = lookup_field
class Meta(object):
"""Defines meta information for the ModelSerializer.
model is set dynamically in __init__.
"""
fields = ("id", "url")
......@@ -4,10 +4,13 @@ Utilities related to API views
import functools
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.http import Http404
from django.utils.translation import ugettext as _
from rest_framework import status, response
from rest_framework.exceptions import APIException
from rest_framework.response import Response
from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
from rest_framework.generics import GenericAPIView
from lms.djangoapps.courseware.courses import get_course_with_access
from opaque_keys.edx.keys import CourseKey
......@@ -122,3 +125,51 @@ def view_auth_classes(is_user=False):
func_or_class.permission_classes += (IsUserInUrl,)
return func_or_class
return _decorator
def add_serializer_errors(serializer, data, field_errors):
"""Adds errors from serializer validation to field_errors. data is the original data to deserialize."""
if not serializer.is_valid(): # pylint: disable=maybe-no-member
errors = serializer.errors # pylint: disable=maybe-no-member
for key, error in errors.iteritems():
field_errors[key] = {
'developer_message': u"Value '{field_value}' is not valid for field '{field_name}': {error}".format(
field_value=data.get(key, ''), field_name=key, error=error
),
'user_message': _(u"This value is invalid."),
}
return field_errors
class RetrievePatchAPIView(RetrieveModelMixin, UpdateModelMixin, GenericAPIView):
"""Concrete view for retrieving and updating a model instance.
Like DRF's RetrieveUpdateAPIView, but without PUT and with automatic validation errors in the edX format.
"""
def get(self, request, *args, **kwargs):
"""Retrieves the specified resource using the RetrieveModelMixin."""
return self.retrieve(request, *args, **kwargs)
def patch(self, request, *args, **kwargs):
"""Checks for validation errors, then updates the model using the UpdateModelMixin."""
field_errors = self._validate_patch(request.DATA)
if field_errors:
return Response({'field_errors': field_errors}, status=status.HTTP_400_BAD_REQUEST)
return self.partial_update(request, *args, **kwargs)
def _validate_patch(self, patch):
"""Validates a JSON merge patch. Captures DRF serializer errors and converts them to edX's standard format."""
field_errors = {}
serializer = self.get_serializer(self.get_object_or_none(), data=patch, partial=True)
fields = self.get_serializer().get_fields() # pylint: disable=maybe-no-member
for key in patch:
if key in fields and fields[key].read_only:
field_errors[key] = {
'developer_message': "This field is not editable",
'user_message': _("This field is not editable"),
}
add_serializer_errors(serializer, patch, field_errors)
return field_errors
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