TNL-1897 Implement Course Team API
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
39
lms/djangoapps/teams/api_urls.py
Normal file
39
lms/djangoapps/teams/api_urls.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""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"
|
||||
)
|
||||
)
|
||||
110
lms/djangoapps/teams/migrations/0001_initial.py
Normal file
110
lms/djangoapps/teams/migrations/0001_initial.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# -*- 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']
|
||||
0
lms/djangoapps/teams/migrations/__init__.py
Normal file
0
lms/djangoapps/teams/migrations/__init__.py
Normal file
80
lms/djangoapps/teams/models.py
Normal file
80
lms/djangoapps/teams/models.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""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)
|
||||
115
lms/djangoapps/teams/serializers.py
Normal file
115
lms/djangoapps/teams/serializers.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""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
|
||||
0
lms/djangoapps/teams/tests/__init__.py
Normal file
0
lms/djangoapps/teams/tests/__init__.py
Normal file
19
lms/djangoapps/teams/tests/factories.py
Normal file
19
lms/djangoapps/teams/tests/factories.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""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"
|
||||
@@ -1,20 +1,25 @@
|
||||
"""
|
||||
Tests for views.py
|
||||
"""
|
||||
from nose.plugins.attrib import attr
|
||||
from student.tests.factories import (
|
||||
CourseEnrollmentFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.http import Http404
|
||||
# -*- 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 rest_framework.test import APIClient
|
||||
from nose.plugins.attrib import attr
|
||||
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 .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)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
URLs for teams.
|
||||
"""
|
||||
from django.conf.urls import patterns, url
|
||||
from teams.views import TeamsDashboardView
|
||||
"""Defines the URL routes for this app."""
|
||||
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
"""
|
||||
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):
|
||||
|
||||
22
openedx/core/lib/api/fields.py
Normal file
22
openedx/core/lib/api/fields.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user