diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 507a94865e..456796ce9f 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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. diff --git a/common/djangoapps/util/model_utils.py b/common/djangoapps/util/model_utils.py index cbd67a5204..41ca3bfa2d 100644 --- a/common/djangoapps/util/model_utils.py +++ b/common/djangoapps/util/model_utils.py @@ -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 diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index e3a1b36faa..a9008d5511 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -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. diff --git a/lms/djangoapps/teams/api_urls.py b/lms/djangoapps/teams/api_urls.py new file mode 100644 index 0000000000..bf4f0da8f7 --- /dev/null +++ b/lms/djangoapps/teams/api_urls.py @@ -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[a-z\d_-]+)' +USERNAME_PATTERN = r'(?P[\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" + ) +) diff --git a/lms/djangoapps/teams/migrations/0001_initial.py b/lms/djangoapps/teams/migrations/0001_initial.py new file mode 100644 index 0000000000..4e73904d2a --- /dev/null +++ b/lms/djangoapps/teams/migrations/0001_initial.py @@ -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'] \ No newline at end of file diff --git a/lms/djangoapps/teams/migrations/__init__.py b/lms/djangoapps/teams/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/teams/models.py b/lms/djangoapps/teams/models.py new file mode 100644 index 0000000000..15874a69b2 --- /dev/null +++ b/lms/djangoapps/teams/models.py @@ -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) diff --git a/lms/djangoapps/teams/serializers.py b/lms/djangoapps/teams/serializers.py new file mode 100644 index 0000000000..90d12f86de --- /dev/null +++ b/lms/djangoapps/teams/serializers.py @@ -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 diff --git a/lms/djangoapps/teams/tests/__init__.py b/lms/djangoapps/teams/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/teams/tests/factories.py b/lms/djangoapps/teams/tests/factories.py new file mode 100644 index 0000000000..23a04343a3 --- /dev/null +++ b/lms/djangoapps/teams/tests/factories.py @@ -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" diff --git a/lms/djangoapps/teams/tests/test_views.py b/lms/djangoapps/teams/tests/test_views.py index 4c75e69384..49ba25c67f 100644 --- a/lms/djangoapps/teams/tests/test_views.py +++ b/lms/djangoapps/teams/tests/test_views.py @@ -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) diff --git a/lms/djangoapps/teams/urls.py b/lms/djangoapps/teams/urls.py index f6dbcea634..5f8d9aa941 100644 --- a/lms/djangoapps/teams/urls.py +++ b/lms/djangoapps/teams/urls.py @@ -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") ) diff --git a/lms/djangoapps/teams/views.py b/lms/djangoapps/teams/views.py index 9a856a50b8..1d67ba498e 100644 --- a/lms/djangoapps/teams/views.py +++ b/lms/djangoapps/teams/views.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 4e5b1e6914..f1417b41e7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1792,6 +1792,9 @@ INSTALLED_APPS = ( 'rest_framework', 'openedx.core.djangoapps.user_api', + # Team API + 'teams', + # Shopping cart 'shoppingcart', diff --git a/lms/urls.py b/lms/urls.py index f06f358b7c..1f2d537a78 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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"), ) diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index c619e5c164..d04709282c 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -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): diff --git a/openedx/core/lib/api/fields.py b/openedx/core/lib/api/fields.py new file mode 100644 index 0000000000..17d2de0b9a --- /dev/null +++ b/openedx/core/lib/api/fields.py @@ -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) diff --git a/openedx/core/lib/api/permissions.py b/openedx/core/lib/api/permissions.py index 6ca1cf2362..a48c577988 100644 --- a/openedx/core/lib/api/permissions.py +++ b/openedx/core/lib/api/permissions.py @@ -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) diff --git a/openedx/core/lib/api/serializers.py b/openedx/core/lib/api/serializers.py index 5e83e2f666..abd8e64dfe 100644 --- a/openedx/core/lib/api/serializers.py +++ b/openedx/core/lib/api/serializers.py @@ -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") diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 5ad1de138f..c98394fd9f 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -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