Skip to content
Snippets Groups Projects
Commit f01b2f43 authored by Ben McMorran's avatar Ben McMorran
Browse files

Merge pull request #8243 from edx/benmcmorran/team-api-a

TNL-1897: Implement Course Team API, Part A
parents 9f6e6281 9d336c33
No related merge requests found
Showing
with 1559 additions and 45 deletions
......@@ -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")
)
This diff is collapsed.
......@@ -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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment