Commit 9d336c33 by Ben McMorran

TNL-1897 Implement Course Team API

parent 0db8bac7
......@@ -38,6 +38,8 @@ from track import contexts
from eventtracking import tracker
from importlib import import_module
from south.modelsinspector import add_introspection_rules
from opaque_keys.edx.locations import SlashSeparatedCourseKey
import lms.lib.comment_client as cc
......@@ -1750,6 +1752,33 @@ class EntranceExamConfiguration(models.Model):
return can_skip
class LanguageField(models.CharField):
"""Represents a language from the ISO 639-1 language set."""
def __init__(self, *args, **kwargs):
"""Creates a LanguageField.
Accepts all the same kwargs as a CharField, except for max_length and
choices. help_text defaults to a description of the ISO 639-1 set.
"""
kwargs.pop('max_length', None)
kwargs.pop('choices', None)
help_text = kwargs.pop(
'help_text',
_("The ISO 639-1 language code for this language."),
)
super(LanguageField, self).__init__(
max_length=16,
choices=settings.ALL_LANGUAGES,
help_text=help_text,
*args,
**kwargs
)
add_introspection_rules([], [r"^student\.models\.LanguageField"])
class LanguageProficiency(models.Model):
"""
Represents a user's language proficiency.
......
"""
Utilities for django models.
"""
import unicodedata
import re
from eventtracking import tracker
from django.conf import settings
from django.utils.encoding import force_unicode
from django.utils.safestring import mark_safe
from django_countries.fields import Country
......@@ -143,3 +148,47 @@ def _get_truncated_setting_value(value, max_length=None):
return value[0:max_length], True
else:
return value, False
# Taken from Django 1.8 source code because it's not supported in 1.4
def slugify(value):
"""Converts value into a string suitable for readable URLs.
Converts to ASCII. Converts spaces to hyphens. Removes characters that
aren't alphanumerics, underscores, or hyphens. Converts to lowercase.
Also strips leading and trailing whitespace.
Args:
value (string): String to slugify.
"""
value = force_unicode(value)
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
return mark_safe(re.sub(r'[-\s]+', '-', value))
def generate_unique_readable_id(name, queryset, lookup_field):
"""Generates a unique readable id from name by appending a numeric suffix.
Args:
name (string): Name to generate the id from. May include spaces.
queryset (QuerySet): QuerySet to check for uniqueness within.
lookup_field (string): Field name on the model that corresponds to the
unique identifier.
Returns:
string: generated unique identifier
"""
candidate = slugify(name)
conflicts = queryset.filter(**{lookup_field + '__startswith': candidate}).values_list(lookup_field, flat=True)
if conflicts and candidate in conflicts:
suffix = 2
while True:
new_id = candidate + '-' + str(suffix)
if new_id not in conflicts:
candidate = new_id
break
suffix += 1
return candidate
......@@ -303,9 +303,9 @@ class TeamsConfigurationTestCase(unittest.TestCase):
""" Make a sample topic dictionary. """
next_num = self.count.next()
topic_id = "topic_id_{}".format(next_num)
display_name = "Display Name {}".format(next_num)
name = "Name {}".format(next_num)
description = "Description {}".format(next_num)
return {"display_name": display_name, "description": description, "id": topic_id}
return {"name": name, "description": description, "id": topic_id}
def test_teams_enabled_new_course(self):
# Make sure we can detect when no teams exist.
......
"""Defines the URL routes for the Team API."""
from django.conf import settings
from django.conf.urls import patterns, url
from .views import (
TeamsListView,
TeamsDetailView,
TopicDetailView,
TopicListView
)
TEAM_ID_PATTERN = r'(?P<team_id>[a-z\d_-]+)'
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
TOPIC_ID_PATTERN = TEAM_ID_PATTERN.replace('team_id', 'topic_id')
urlpatterns = patterns(
'',
url(
r'^v0/teams$',
TeamsListView.as_view(),
name="teams_list"
),
url(
r'^v0/teams/' + TEAM_ID_PATTERN + '$',
TeamsDetailView.as_view(),
name="teams_detail"
),
url(
r'^v0/topics/$',
TopicListView.as_view(),
name="topics_list"
),
url(
r'^v0/topics/' + TOPIC_ID_PATTERN + ',' + settings.COURSE_ID_PATTERN + '$',
TopicDetailView.as_view(),
name="topics_detail"
)
)
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseTeam'
db.create_table('teams_courseteam', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('team_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)),
('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
('topic_id', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, blank=True)),
('date_created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('description', self.gf('django.db.models.fields.CharField')(max_length=300)),
('country', self.gf('django_countries.fields.CountryField')(max_length=2, blank=True)),
('language', self.gf('student.models.LanguageField')(max_length=16, blank=True)),
))
db.send_create_signal('teams', ['CourseTeam'])
# Adding model 'CourseTeamMembership'
db.create_table('teams_courseteammembership', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('team', self.gf('django.db.models.fields.related.ForeignKey')(related_name='membership', to=orm['teams.CourseTeam'])),
('date_joined', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
))
db.send_create_signal('teams', ['CourseTeamMembership'])
# Adding unique constraint on 'CourseTeamMembership', fields ['user', 'team']
db.create_unique('teams_courseteammembership', ['user_id', 'team_id'])
def backwards(self, orm):
# Removing unique constraint on 'CourseTeamMembership', fields ['user', 'team']
db.delete_unique('teams_courseteammembership', ['user_id', 'team_id'])
# Deleting model 'CourseTeam'
db.delete_table('teams_courseteam')
# Deleting model 'CourseTeamMembership'
db.delete_table('teams_courseteammembership')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'teams.courseteam': {
'Meta': {'object_name': 'CourseTeam'},
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'description': ('django.db.models.fields.CharField', [], {'max_length': '300'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'language': ('student.models.LanguageField', [], {'max_length': '16', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'team_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
'topic_id': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'teams'", 'symmetrical': 'False', 'through': "orm['teams.CourseTeamMembership']", 'to': "orm['auth.User']"})
},
'teams.courseteammembership': {
'Meta': {'unique_together': "(('user', 'team'),)", 'object_name': 'CourseTeamMembership'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'team': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'membership'", 'to': "orm['teams.CourseTeam']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['teams']
\ No newline at end of file
"""Django models related to teams functionality."""
from django.contrib.auth.models import User
from django.db import models
from django.utils.translation import ugettext_lazy
from django_countries.fields import CountryField
from xmodule_django.models import CourseKeyField
from util.model_utils import generate_unique_readable_id
from student.models import LanguageField
class CourseTeam(models.Model):
"""This model represents team related info."""
team_id = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=255)
is_active = models.BooleanField(default=True)
course_id = CourseKeyField(max_length=255, db_index=True)
topic_id = models.CharField(max_length=255, db_index=True, blank=True)
date_created = models.DateTimeField(auto_now_add=True)
# last_activity is computed through a query
description = models.CharField(max_length=300)
country = CountryField(blank=True)
language = LanguageField(
blank=True,
help_text=ugettext_lazy("Optional language the team uses as ISO 639-1 code."),
)
users = models.ManyToManyField(User, db_index=True, related_name='teams', through='CourseTeamMembership')
@classmethod
def create(cls, name, course_id, description, topic_id=None, country=None, language=None):
"""Create a complete CourseTeam object.
Args:
name (str): The name of the team to be created.
course_id (str): The ID string of the course associated
with this team.
description (str): A description of the team.
topic_id (str): An optional identifier for the topic the
team formed around.
country (str, optional): An optional country where the team
is based, as ISO 3166-1 code.
language (str, optional): An optional language which the
team uses, as ISO 639-1 code.
"""
team_id = generate_unique_readable_id(name, cls.objects.all(), 'team_id')
course_team = cls(
team_id=team_id,
name=name,
course_id=course_id,
topic_id=topic_id if topic_id else '',
description=description,
country=country if country else '',
language=language if language else '',
)
return course_team
def add_user(self, user):
"""Adds the given user to the CourseTeam."""
CourseTeamMembership.objects.get_or_create(
user=user,
team=self
)
class CourseTeamMembership(models.Model):
"""This model represents the membership of a single user in a single team."""
class Meta(object):
"""Stores meta information for the model."""
unique_together = (('user', 'team'),)
user = models.ForeignKey(User)
team = models.ForeignKey(CourseTeam, related_name='membership')
date_joined = models.DateTimeField(auto_now_add=True)
"""Defines serializers used by the Team API."""
from django.contrib.auth.models import User
from rest_framework import serializers
from openedx.core.lib.api.serializers import CollapsedReferenceSerializer
from openedx.core.lib.api.fields import ExpandableField
from .models import CourseTeam, CourseTeamMembership
from openedx.core.djangoapps.user_api.serializers import UserSerializer
class UserMembershipSerializer(serializers.ModelSerializer):
"""Serializes CourseTeamMemberships with only user and date_joined
Used for listing team members.
"""
user = ExpandableField(
collapsed_serializer=CollapsedReferenceSerializer(
model_class=User,
id_source='username',
view_name='accounts_api',
read_only=True,
),
expanded_serializer=UserSerializer(),
)
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeamMembership
fields = ("user", "date_joined")
read_only_fields = ("date_joined",)
class CourseTeamSerializer(serializers.ModelSerializer):
"""Serializes a CourseTeam with membership information."""
id = serializers.CharField(source='team_id', read_only=True) # pylint: disable=invalid-name
membership = UserMembershipSerializer(many=True, read_only=True)
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeam
fields = (
"id",
"name",
"is_active",
"course_id",
"topic_id",
"date_created",
"description",
"country",
"language",
"membership",
)
read_only_fields = ("course_id", "date_created")
class CourseTeamCreationSerializer(serializers.ModelSerializer):
"""Deserializes a CourseTeam for creation."""
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeam
fields = (
"name",
"course_id",
"description",
"topic_id",
"country",
"language",
)
def restore_object(self, attrs, instance=None):
"""Restores a CourseTeam instance from the given attrs."""
return CourseTeam.create(
name=attrs.get("name", ''),
course_id=attrs.get("course_id"),
description=attrs.get("description", ''),
topic_id=attrs.get("topic_id", ''),
country=attrs.get("country", ''),
language=attrs.get("language", ''),
)
class MembershipSerializer(serializers.ModelSerializer):
"""Serializes CourseTeamMemberships with information about both teams and users."""
user = ExpandableField(
collapsed_serializer=CollapsedReferenceSerializer(
model_class=User,
id_source='username',
view_name='accounts_api',
read_only=True,
),
expanded_serializer=UserSerializer(read_only=True)
)
team = ExpandableField(
collapsed_serializer=CollapsedReferenceSerializer(
model_class=CourseTeam,
id_source='team_id',
view_name='teams_detail',
read_only=True,
),
expanded_serializer=CourseTeamSerializer(read_only=True)
)
class Meta(object):
"""Defines meta information for the ModelSerializer."""
model = CourseTeamMembership
fields = ("user", "team", "date_joined")
read_only_fields = ("date_joined",)
class TopicSerializer(serializers.Serializer):
"""Serializes a topic."""
description = serializers.CharField()
name = serializers.CharField()
id = serializers.CharField() # pylint: disable=invalid-name
"""Factories for testing the Teams API."""
import factory
from factory.django import DjangoModelFactory
from ..models import CourseTeam
class CourseTeamFactory(DjangoModelFactory):
"""Factory for CourseTeams.
Note that team_id is not auto-generated from name when using the factory.
"""
FACTORY_FOR = CourseTeam
FACTORY_DJANGO_GET_OR_CREATE = ('team_id',)
team_id = factory.Sequence('team-{0}'.format)
name = "Awesome Team"
description = "A simple description"
"""
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")
)
......@@ -1792,6 +1792,9 @@ INSTALLED_APPS = (
'rest_framework',
'openedx.core.djangoapps.user_api',
# Team API
'teams',
# Shopping cart
'shoppingcart',
......
......@@ -431,6 +431,7 @@ if settings.COURSEWARE_ENABLED:
if settings.FEATURES["ENABLE_TEAMS"]:
# Teams endpoints
urlpatterns += (
url(r'^api/team/', include('teams.api_urls')),
url(r'^courses/{}/teams'.format(settings.COURSE_ID_PATTERN), include('teams.urls'), name="teams_endpoints"),
)
......
......@@ -10,6 +10,8 @@ from student.models import User, UserProfile, Registration
from student import views as student_views
from util.model_utils import emit_setting_changed_event
from openedx.core.lib.api.view_utils import add_serializer_errors
from ..errors import (
AccountUpdateError, AccountValidationError, AccountUsernameInvalid, AccountPasswordInvalid,
AccountEmailInvalid, AccountUserAlreadyExists,
......@@ -170,7 +172,7 @@ def update_account_settings(requesting_user, update, username=None):
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile, data=update)
for serializer in user_serializer, legacy_profile_serializer:
field_errors = _add_serializer_errors(update, serializer, field_errors)
field_errors = add_serializer_errors(serializer, update, field_errors)
# If the user asked to change email, validate it.
if changing_email:
......@@ -250,27 +252,6 @@ def _get_user_and_profile(username):
return existing_user, existing_user_profile
def _add_serializer_errors(update, serializer, field_errors):
"""
Helper method that adds any validation errors that are present in the serializer to
the supplied field_errors dict.
"""
if not serializer.is_valid():
errors = serializer.errors
for key, error in errors.iteritems():
field_value = update[key]
field_errors[key] = {
"developer_message": u"Value '{field_value}' is not valid for field '{field_name}': {error}".format(
field_value=field_value, field_name=key, error=error
),
"user_message": _(u"This value is invalid.").format(
field_value=field_value, field_name=key
),
}
return field_errors
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError])
@transaction.commit_on_success
def create_account(username, password, email):
......
"""Fields useful for edX API implementations."""
from rest_framework.serializers import Field
class ExpandableField(Field):
"""Field that can dynamically use a more detailed serializer based on a user-provided "expand" parameter."""
def __init__(self, **kwargs):
"""Sets up the ExpandableField with the collapsed and expanded versions of the serializer."""
assert 'collapsed_serializer' in kwargs and 'expanded_serializer' in kwargs
self.collapsed = kwargs.pop('collapsed_serializer')
self.expanded = kwargs.pop('expanded_serializer')
super(ExpandableField, self).__init__(**kwargs)
def field_to_native(self, obj, field_name):
"""Converts obj to a native representation, using the expanded serializer if the context requires it."""
if 'expand' in self.context and field_name in self.context['expand']:
self.expanded.initialize(self, field_name)
return self.expanded.field_to_native(obj, field_name)
else:
self.collapsed.initialize(self, field_name)
return self.collapsed.field_to_native(obj, field_name)
......@@ -2,6 +2,8 @@ from django.conf import settings
from rest_framework import permissions
from django.http import Http404
from student.roles import CourseStaffRole
class ApiKeyHeaderPermission(permissions.BasePermission):
def has_permission(self, request, view):
......@@ -74,3 +76,13 @@ class IsUserInUrlOrStaff(IsUserInUrl):
return True
return super(IsUserInUrlOrStaff, self).has_permission(request, view)
class IsStaffOrReadOnly(permissions.BasePermission):
"""Permission that checks to see if the user is global or course
staff, permitting only read-only access if they are not.
"""
def has_object_permission(self, request, view, obj):
return (request.user.is_staff or
CourseStaffRole(obj.course_id).has_user(request.user) or
request.method in permissions.SAFE_METHODS)
......@@ -6,3 +6,40 @@ class PaginationSerializer(pagination.PaginationSerializer):
Custom PaginationSerializer to include num_pages field
"""
num_pages = serializers.Field(source='paginator.num_pages')
class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
"""Serializes arbitrary models in a collapsed format, with just an id and url."""
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
url = serializers.HyperlinkedIdentityField(view_name='')
def __init__(self, model_class, view_name, id_source='id', lookup_field=None, *args, **kwargs):
"""Configures the serializer.
Args:
model_class (class): Model class to serialize.
view_name (string): Name of the Django view used to lookup the
model.
id_source (string): Optional name of the id field on the model.
Defaults to 'id'.
lookup_field (string): Optional name of the model field used to
lookup the model in the view. Defaults to the value of
id_source.
"""
if not lookup_field:
lookup_field = id_source
self.Meta.model = model_class
super(CollapsedReferenceSerializer, self).__init__(*args, **kwargs)
self.fields['id'].source = id_source
self.fields['url'].view_name = view_name
self.fields['url'].lookup_field = lookup_field
class Meta(object):
"""Defines meta information for the ModelSerializer.
model is set dynamically in __init__.
"""
fields = ("id", "url")
......@@ -4,10 +4,13 @@ Utilities related to API views
import functools
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.http import Http404
from django.utils.translation import ugettext as _
from rest_framework import status, response
from rest_framework.exceptions import APIException
from rest_framework.response import Response
from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
from rest_framework.generics import GenericAPIView
from lms.djangoapps.courseware.courses import get_course_with_access
from opaque_keys.edx.keys import CourseKey
......@@ -122,3 +125,51 @@ def view_auth_classes(is_user=False):
func_or_class.permission_classes += (IsUserInUrl,)
return func_or_class
return _decorator
def add_serializer_errors(serializer, data, field_errors):
"""Adds errors from serializer validation to field_errors. data is the original data to deserialize."""
if not serializer.is_valid(): # pylint: disable=maybe-no-member
errors = serializer.errors # pylint: disable=maybe-no-member
for key, error in errors.iteritems():
field_errors[key] = {
'developer_message': u"Value '{field_value}' is not valid for field '{field_name}': {error}".format(
field_value=data.get(key, ''), field_name=key, error=error
),
'user_message': _(u"This value is invalid."),
}
return field_errors
class RetrievePatchAPIView(RetrieveModelMixin, UpdateModelMixin, GenericAPIView):
"""Concrete view for retrieving and updating a model instance.
Like DRF's RetrieveUpdateAPIView, but without PUT and with automatic validation errors in the edX format.
"""
def get(self, request, *args, **kwargs):
"""Retrieves the specified resource using the RetrieveModelMixin."""
return self.retrieve(request, *args, **kwargs)
def patch(self, request, *args, **kwargs):
"""Checks for validation errors, then updates the model using the UpdateModelMixin."""
field_errors = self._validate_patch(request.DATA)
if field_errors:
return Response({'field_errors': field_errors}, status=status.HTTP_400_BAD_REQUEST)
return self.partial_update(request, *args, **kwargs)
def _validate_patch(self, patch):
"""Validates a JSON merge patch. Captures DRF serializer errors and converts them to edX's standard format."""
field_errors = {}
serializer = self.get_serializer(self.get_object_or_none(), data=patch, partial=True)
fields = self.get_serializer().get_fields() # pylint: disable=maybe-no-member
for key in patch:
if key in fields and fields[key].read_only:
field_errors[key] = {
'developer_message': "This field is not editable",
'user_message': _("This field is not editable"),
}
add_serializer_errors(serializer, patch, field_errors)
return field_errors
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment