Commit 695f19c4 by Ben McMorran

TNL-1907 Implement Course Team Membership API

parent 5faaca0d
......@@ -7,7 +7,9 @@ from .views import (
TeamsListView,
TeamsDetailView,
TopicDetailView,
TopicListView
TopicListView,
MembershipListView,
MembershipDetailView
)
TEAM_ID_PATTERN = r'(?P<team_id>[a-z\d_-]+)'
......@@ -35,5 +37,15 @@ urlpatterns = patterns(
r'^v0/topics/' + TOPIC_ID_PATTERN + ',' + settings.COURSE_ID_PATTERN + '$',
TopicDetailView.as_view(),
name="topics_detail"
),
url(
r'^v0/team_membership$',
MembershipListView.as_view(),
name="team_membership_list"
),
url(
r'^v0/team_membership/' + TEAM_ID_PATTERN + ',' + USERNAME_PATTERN + '$',
MembershipDetailView.as_view(),
name="team_membership_detail"
)
)
"""Errors thrown in the Team API"""
class TeamAPIRequestError(Exception):
"""There was a problem with a request to the Team API."""
pass
class NotEnrolledInCourseForTeam(TeamAPIRequestError):
"""User is not enrolled in the course for the team they are trying to join."""
pass
class AlreadyOnTeamInCourse(TeamAPIRequestError):
"""User is already a member of another team in the same course."""
pass
......@@ -7,7 +7,8 @@ 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
from student.models import LanguageField, CourseEnrollment
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
class CourseTeam(models.Model):
......@@ -62,7 +63,11 @@ class CourseTeam(models.Model):
def add_user(self, user):
"""Adds the given user to the CourseTeam."""
CourseTeamMembership.objects.get_or_create(
if not CourseEnrollment.is_enrolled(user, self.course_id):
raise NotEnrolledInCourseForTeam
if CourseTeamMembership.objects.filter(user=user, team__course_id=self.course_id).exists():
raise AlreadyOnTeamInCourse
return CourseTeamMembership.objects.create(
user=user,
team=self
)
......
......@@ -121,6 +121,12 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
self.users = {
'student_unenrolled': UserFactory.create(password=self.test_password),
'student_enrolled': UserFactory.create(password=self.test_password),
'student_enrolled_not_on_team': UserFactory.create(password=self.test_password),
# This student is enrolled in both test courses and is a member of a team in each course, but is not on the
# same team as student_enrolled.
'student_enrolled_both_courses_other_team': 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)
}
......@@ -135,11 +141,19 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
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'])
for user, course in [
('student_enrolled', self.test_course_1),
('student_enrolled_not_on_team', self.test_course_1),
('student_enrolled_both_courses_other_team', self.test_course_1),
('student_enrolled_both_courses_other_team', self.test_course_2)
]:
CourseEnrollment.enroll(
self.users[user], course.id, check_access=True
)
CourseEnrollment.enroll(
self.users['student_enrolled'], self.test_course_1.id, check_access=True
)
self.test_team_1.add_user(self.users['student_enrolled'])
self.test_team_3.add_user(self.users['student_enrolled_both_courses_other_team'])
self.test_team_4.add_user(self.users['student_enrolled_both_courses_other_team'])
def login(self, user):
"""Given a user string, logs the given user in.
......@@ -197,9 +211,9 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
"""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):
def get_team_detail(self, team_id, expected_status=200, data=None, **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)
return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'get', data, **kwargs)
def patch_team_detail(self, team_id, expected_status, data=None, **kwargs):
"""Patches the team with team_id using data. Verifies expected_status."""
......@@ -226,14 +240,51 @@ class TeamAPITestCase(APITestCase, ModuleStoreTestCase):
**kwargs
)
def get_membership_list(self, expected_status=200, data=None, **kwargs):
"""Gets the membership list, passing data as query params. Verifies expected_status."""
return self.make_call(reverse('team_membership_list'), expected_status, 'get', data, **kwargs)
def post_create_membership(self, expected_status=200, data=None, **kwargs):
"""Posts data to the membership creation endpoint. Verifies expected_status."""
return self.make_call(reverse('team_membership_list'), expected_status, 'post', data, **kwargs)
def get_membership_detail(self, team_id, username, expected_status=200, data=None, **kwargs):
"""Gets an individual membership record, passing data as query params. Verifies expected_status."""
return self.make_call(
reverse('team_membership_detail', args=[team_id, username]),
expected_status,
'get',
data,
**kwargs
)
def delete_membership(self, team_id, username, expected_status=200, **kwargs):
"""Deletes an individual membership record. Verifies expected_status."""
return self.make_call(
reverse('team_membership_detail', args=[team_id, username]),
expected_status,
'delete',
**kwargs
)
def verify_expanded_user(self, user):
"""Verifies that fields exist on the returned user json indicating that it is expanded."""
for field in ['id', 'url', 'email', 'name', 'username', 'preferences']:
self.assertIn(field, user)
def verify_expanded_team(self, team):
"""Verifies that fields exist on the returned team json indicating that it is expanded."""
for field in ['id', 'name', 'is_active', 'course_id', 'topic_id', 'date_created', 'description']:
self.assertIn(field, team)
@ddt.ddt
class TestListTeamsAPI(TeamAPITestCase):
"""Test cases for the team listing API endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
......@@ -255,7 +306,7 @@ class TestListTeamsAPI(TeamAPITestCase):
self.assertEqual(names, [team['name'] for team in teams['results']])
def test_filter_invalid_course_id(self):
self.verify_names({'course_id': 'foobar'}, 400)
self.verify_names({'course_id': 'no_such_course'}, 400)
def test_filter_course_id(self):
self.verify_names({'course_id': self.test_course_2.id}, 200, ['Another Team'], user='staff')
......@@ -274,7 +325,7 @@ class TestListTeamsAPI(TeamAPITestCase):
@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']),
('open_slots', 200, ['Wind Team', u'sólar team', 'Nuclear Team']),
('last_activity', 400, []),
)
@ddt.unpack
......@@ -282,7 +333,7 @@ class TestListTeamsAPI(TeamAPITestCase):
data = {'order_by': field} if field else {}
self.verify_names(data, status, names)
@ddt.data({'course_id': 'foobar/foobar/foobar'}, {'topic_id': 'foobar'})
@ddt.data({'course_id': 'no/such/course'}, {'topic_id': 'no_such_topic'})
def test_no_results(self, data):
self.get_teams_list(404, data)
......@@ -296,14 +347,18 @@ class TestListTeamsAPI(TeamAPITestCase):
self.assertIsNone(result['next'])
self.assertIsNotNone(result['previous'])
def test_expand_user(self):
result = self.get_teams_list(200, {'expand': 'user', 'topic_id': 'renewable'})
self.verify_expanded_user(result['results'][0]['membership'][0]['user'])
@ddt.ddt
class TestCreateTeamAPI(TeamAPITestCase):
"""Test cases for the team creation endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
......@@ -329,11 +384,11 @@ class TestCreateTeamAPI(TeamAPITestCase):
@ddt.data((400, {
'name': 'Bad Course Id',
'course_id': 'foobar',
'course_id': 'no_such_course',
'description': "Filler Description"
}), (404, {
'name': "Non-existent course id",
'course_id': 'foobar/foobar/foobar',
'course_id': 'no/such/course',
'description': "Filler Description"
}))
@ddt.unpack
......@@ -380,8 +435,8 @@ class TestDetailTeamAPI(TeamAPITestCase):
"""Test cases for the team detail endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
......@@ -394,7 +449,11 @@ class TestDetailTeamAPI(TeamAPITestCase):
self.assertEquals(team['description'], self.test_team_1.description)
def test_does_not_exist(self):
self.get_team_detail('foobar', 404)
self.get_team_detail('no_such_team', 404)
def test_expand_user(self):
result = self.get_team_detail(self.test_team_1.team_id, 200, {'expand': 'user'})
self.verify_expanded_user(result['membership'][0]['user'])
@ddt.ddt
......@@ -402,8 +461,8 @@ class TestUpdateTeamAPI(TeamAPITestCase):
"""Test cases for the team update endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 403),
('staff', 200),
......@@ -416,8 +475,8 @@ class TestUpdateTeamAPI(TeamAPITestCase):
self.assertEquals(team['name'], 'foo')
@ddt.data(
(None, 403),
('student_inactive', 403),
(None, 401),
('student_inactive', 401),
('student_unenrolled', 404),
('student_enrolled', 404),
('staff', 404),
......@@ -425,13 +484,13 @@ class TestUpdateTeamAPI(TeamAPITestCase):
)
@ddt.unpack
def test_access_bad_id(self, user, status):
self.patch_team_detail("foobar", status, {'name': 'foo'}, user=user)
self.patch_team_detail("no_such_team", status, {'name': 'foo'}, user=user)
@ddt.data(
('id', 'foobar'),
('description', ''),
('country', 'foobar'),
('language', 'foobar')
('country', 'no_such_country'),
('language', 'no_such_language')
)
@ddt.unpack
def test_bad_requests(self, key, value):
......@@ -443,7 +502,7 @@ class TestUpdateTeamAPI(TeamAPITestCase):
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')
self.patch_team_detail('no_such_team', 404, user='staff')
@ddt.ddt
......@@ -451,8 +510,8 @@ class TestListTopicsAPI(TeamAPITestCase):
"""Test cases for the topic listing endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
......@@ -474,7 +533,7 @@ class TestListTopicsAPI(TeamAPITestCase):
@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, []),
('no_such_field', 400, []),
)
@ddt.unpack
def test_order_by(self, field, status, names):
......@@ -503,8 +562,8 @@ class TestDetailTopicAPI(TeamAPITestCase):
"""Test cases for the topic detail endpoint."""
@ddt.data(
(None, 403),
('student_inactive', 403),
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 200),
('staff', 200),
......@@ -522,4 +581,227 @@ class TestDetailTopicAPI(TeamAPITestCase):
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)
self.get_topic_detail('no_such_topic', self.test_course_1.id, 404)
@ddt.ddt
class TestListMembershipAPI(TeamAPITestCase):
"""Test cases for the membership list endpoint."""
@ddt.data(
(None, 401),
('student_inactive', 401),
('student_unenrolled', 404),
('student_enrolled', 200),
('student_enrolled_both_courses_other_team', 200),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
membership = self.get_membership_list(status, {'team_id': self.test_team_1.team_id}, user=user)
if status == 200:
self.assertEqual(membership['count'], 1)
self.assertEqual(membership['results'][0]['user']['id'], self.users['student_enrolled'].username)
@ddt.data(
(None, 401, False),
('student_inactive', 401, False),
('student_unenrolled', 200, False),
('student_enrolled', 200, True),
('student_enrolled_both_courses_other_team', 200, True),
('staff', 200, True),
('course_staff', 200, True),
)
@ddt.unpack
def test_access_by_username(self, user, status, has_content):
membership = self.get_membership_list(status, {'username': self.users['student_enrolled'].username}, user=user)
if status == 200:
if has_content:
self.assertEqual(membership['count'], 1)
self.assertEqual(membership['results'][0]['team']['id'], self.test_team_1.team_id)
else:
self.assertEqual(membership['count'], 0)
def test_no_username_or_team_id(self):
self.get_membership_list(400, {})
def test_bad_team_id(self):
self.get_membership_list(404, {'team_id': 'no_such_team'})
def test_expand_user(self):
result = self.get_membership_list(200, {'team_id': self.test_team_1.team_id, 'expand': 'user'})
self.verify_expanded_user(result['results'][0]['user'])
def test_expand_team(self):
result = self.get_membership_list(200, {'team_id': self.test_team_1.team_id, 'expand': 'team'})
self.verify_expanded_team(result['results'][0]['team'])
@ddt.ddt
class TestCreateMembershipAPI(TeamAPITestCase):
"""Test cases for the membership creation endpoint."""
def build_membership_data_raw(self, username, team):
"""Assembles a membership creation payload based on the raw values provided."""
return {'username': username, 'team_id': team}
def build_membership_data(self, username, team):
"""Assembles a membership creation payload based on the username and team model provided."""
return self.build_membership_data_raw(self.users[username].username, team.team_id)
@ddt.data(
(None, 401),
('student_inactive', 401),
('student_unenrolled', 404),
('student_enrolled_not_on_team', 200),
('student_enrolled', 404),
('student_enrolled_both_courses_other_team', 404),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
membership = self.post_create_membership(
status,
self.build_membership_data('student_enrolled_not_on_team', self.test_team_1),
user=user
)
if status == 200:
self.assertEqual(membership['user']['id'], self.users['student_enrolled_not_on_team'].username)
self.assertEqual(membership['team']['id'], self.test_team_1.team_id)
memberships = self.get_membership_list(200, {'team_id': self.test_team_1.team_id})
self.assertEqual(memberships['count'], 2)
def test_no_username(self):
response = self.post_create_membership(400, {'team_id': self.test_team_1.team_id})
self.assertIn('username', json.loads(response.content)['field_errors'])
def test_no_team(self):
response = self.post_create_membership(400, {'username': self.users['student_enrolled_not_on_team'].username})
self.assertIn('team_id', json.loads(response.content)['field_errors'])
def test_bad_team(self):
self.post_create_membership(
404,
self.build_membership_data_raw(self.users['student_enrolled'].username, 'no_such_team')
)
def test_bad_username(self):
self.post_create_membership(
404,
self.build_membership_data_raw('no_such_user', self.test_team_1.team_id),
user='staff'
)
@ddt.data('student_enrolled', 'staff', 'course_staff')
def test_join_twice(self, user):
response = self.post_create_membership(
400,
self.build_membership_data('student_enrolled', self.test_team_1),
user=user
)
self.assertIn('already a member', json.loads(response.content)['developer_message'])
def test_join_second_team_in_course(self):
response = self.post_create_membership(
400,
self.build_membership_data('student_enrolled_both_courses_other_team', self.test_team_1),
user='student_enrolled_both_courses_other_team'
)
self.assertIn('already a member', json.loads(response.content)['developer_message'])
@ddt.data('staff', 'course_staff')
def test_not_enrolled_in_team_course(self, user):
response = self.post_create_membership(
400,
self.build_membership_data('student_unenrolled', self.test_team_1),
user=user
)
self.assertIn('not enrolled', json.loads(response.content)['developer_message'])
@ddt.ddt
class TestDetailMembershipAPI(TeamAPITestCase):
"""Test cases for the membership detail endpoint."""
@ddt.data(
(None, 401),
('student_inactive', 401),
('student_unenrolled', 404),
('student_enrolled_not_on_team', 200),
('student_enrolled', 200),
('staff', 200),
('course_staff', 200),
)
@ddt.unpack
def test_access(self, user, status):
self.get_membership_detail(
self.test_team_1.team_id,
self.users['student_enrolled'].username,
status,
user=user
)
def test_bad_team(self):
self.get_membership_detail('no_such_team', self.users['student_enrolled'].username, 404)
def test_bad_username(self):
self.get_membership_detail(self.test_team_1.team_id, 'no_such_user', 404)
def test_no_membership(self):
self.get_membership_detail(
self.test_team_1.team_id,
self.users['student_enrolled_not_on_team'].username,
404
)
def test_expand_user(self):
result = self.get_membership_detail(
self.test_team_1.team_id,
self.users['student_enrolled'].username,
200,
{'expand': 'user'}
)
self.verify_expanded_user(result['user'])
def test_expand_team(self):
result = self.get_membership_detail(
self.test_team_1.team_id,
self.users['student_enrolled'].username,
200,
{'expand': 'team'}
)
self.verify_expanded_team(result['team'])
@ddt.ddt
class TestDeleteMembershipAPI(TeamAPITestCase):
"""Test cases for the membership deletion endpoint."""
@ddt.data(
(None, 401),
('student_inactive', 401),
('student_unenrolled', 404),
('student_enrolled_not_on_team', 404),
('student_enrolled', 204),
('staff', 204),
('course_staff', 204),
)
@ddt.unpack
def test_access(self, user, status):
self.delete_membership(
self.test_team_1.team_id,
self.users['student_enrolled'].username,
status,
user=user
)
def test_bad_team(self):
self.delete_membership('no_such_team', self.users['student_enrolled'].username, 404)
def test_bad_username(self):
self.delete_membership(self.test_team_1.team_id, 'no_such_user', 404)
def test_missing_membership(self):
self.delete_membership(self.test_team_2.team_id, self.users['student_enrolled'].username, 404)
......@@ -18,15 +18,21 @@ from rest_framework import status
from rest_framework import permissions
from django.db.models import Count
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop
from student.models import CourseEnrollment
from student.models import CourseEnrollment, CourseAccessRole
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.view_utils import (
RetrievePatchAPIView,
add_serializer_errors,
build_api_error,
ExpandableFieldViewMixin
)
from openedx.core.lib.api.serializers import PaginationSerializer
from xmodule.modulestore.django import modulestore
......@@ -34,8 +40,9 @@ 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
from .models import CourseTeam, CourseTeamMembership
from .serializers import CourseTeamSerializer, CourseTeamCreationSerializer, TopicSerializer, MembershipSerializer
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
class TeamsDashboardView(View):
......@@ -71,7 +78,7 @@ def is_feature_enabled(course):
return settings.FEATURES.get('ENABLE_TEAMS', False) and course.teams_enabled
def has_team_api_access(user, course_key):
def has_team_api_access(user, course_key, access_username=None):
"""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.
......@@ -79,16 +86,21 @@ def has_team_api_access(user, course_key):
Args:
user (User): The user to check access for.
course_key (CourseKey): The key to the course which we are checking access to.
access_username (string): If provided, access_username must match user.username for non staff access.
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)
if user.is_staff:
return True
if CourseStaffRole(course_key).has_user(user):
return True
if not access_username or access_username == user.username:
return CourseEnrollment.is_enrolled(user, course_key)
return False
class TeamsListView(GenericAPIView):
class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
"""
**Use Cases**
......@@ -125,6 +137,9 @@ class TeamsListView(GenericAPIView):
* include_inactive: If true, inactive teams will be returned. The
default is to not include inactive teams.
* expand: Comma separated list of types for which to return
expanded representations. Supports "user" and "team".
**Response Values for GET**
If the user is logged in and enrolled, the response contains:
......@@ -172,8 +187,10 @@ class TeamsListView(GenericAPIView):
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 user is not logged in, a 401 error is returned.
If the user is not 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.
......@@ -189,8 +206,10 @@ class TeamsListView(GenericAPIView):
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 user is not logged in, a 401 error is returned.
If the user 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.
......@@ -198,8 +217,8 @@ class TeamsListView(GenericAPIView):
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)
# OAuth2Authentication must come first to return a 401 for unauthenticated users
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
paginate_by = 10
......@@ -207,12 +226,6 @@ class TeamsListView(GenericAPIView):
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 = {
......@@ -228,33 +241,29 @@ class TeamsListView(GenericAPIView):
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
error = build_api_error(
ugettext_noop("The supplied course id {course_id} is not valid."),
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)
return Response(error, 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)
return Response(
build_api_error(ugettext_noop("course_id must be provided")),
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)
return Response(
build_api_error(ugettext_noop("text_search is not yet supported.")),
status=status.HTTP_400_BAD_REQUEST
)
queryset = CourseTeam.objects.filter(**result_filter)
......@@ -266,10 +275,10 @@ class TeamsListView(GenericAPIView):
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)
return Response(
build_api_error(ugettext_noop("last_activity is not yet supported")),
status=status.HTTP_400_BAD_REQUEST
)
queryset = queryset.order_by(order_by_field)
......@@ -292,10 +301,10 @@ class TeamsListView(GenericAPIView):
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),
}
field_errors['course_id'] = build_api_error(
ugettext_noop('The supplied course_id {course_id} is not valid.'),
course_id=course_id
)
if course_key and not has_team_api_access(request.user, course_key):
return Response(status=status.HTTP_403_FORBIDDEN)
......@@ -315,7 +324,7 @@ class TeamsListView(GenericAPIView):
return Response(CourseTeamSerializer(team).data)
class TeamsDetailView(RetrievePatchAPIView):
class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
"""
**Use Cases**
......@@ -328,6 +337,11 @@ class TeamsDetailView(RetrievePatchAPIView):
PATCH /api/team/v0/teams/{team_id} "application/merge-patch+json"
**Query Parameters for GET**
* expand: Comma separated list of types for which to return
expanded representations. Supports "user" and "team".
**Response Values for GET**
If the user is logged in, the response contains the following fields:
......@@ -363,8 +377,9 @@ class TeamsDetailView(RetrievePatchAPIView):
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 user is not logged in, a 401 error is returned.
If the user is not course or global staff, a 403 error is returned.
If the specified team does not exist, a 404 error is returned.
......@@ -372,7 +387,8 @@ class TeamsDetailView(RetrievePatchAPIView):
Only staff can patch teams.
If the user is anonymous or inactive, a 403 is returned.
If the user is anonymous or inactive, a 401 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.
......@@ -392,7 +408,7 @@ class TeamsDetailView(RetrievePatchAPIView):
"""Returns true if the user is enrolled or is staff."""
return has_team_api_access(request.user, obj.course_id)
authentication_classes = (SessionAuthentication, OAuth2Authentication)
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, IsStaffOrReadOnly, IsEnrolledOrIsStaff,)
lookup_field = 'team_id'
serializer_class = CourseTeamSerializer
......@@ -427,6 +443,8 @@ class TopicListView(GenericAPIView):
**Response Values for GET**
If the user is not logged in, a 401 error is returned.
If the course_id is not given or an unsupported value is passed for
order_by, returns a 400 error.
......@@ -458,7 +476,7 @@ class TopicListView(GenericAPIView):
"""
authentication_classes = (SessionAuthentication, OAuth2Authentication)
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
paginate_by = 10
......@@ -472,10 +490,10 @@ class TopicListView(GenericAPIView):
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)
}
'course_id': build_api_error(
ugettext_noop("The supplied course id {course_id} is not valid."),
course_id=course_id_string
)
}
}, status=status.HTTP_400_BAD_REQUEST)
......@@ -527,11 +545,13 @@ class TopicDetailView(APIView):
**Response Values for GET**
If the user is not logged in, a 401 error is returned.
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 user 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.
......@@ -545,7 +565,7 @@ class TopicDetailView(APIView):
"""
authentication_classes = (SessionAuthentication, OAuth2Authentication)
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, topic_id, course_id):
......@@ -570,3 +590,322 @@ class TopicDetailView(APIView):
serializer = TopicSerializer(topics[0])
return Response(serializer.data)
class MembershipListView(ExpandableFieldViewMixin, GenericAPIView):
"""
**Use Cases**
List course team memberships or add a user to a course team.
**Example Requests**:
GET /api/team/v0/team_membership
POST /api/team/v0/team_membership
**Query Parameters for GET**
At least one of username and team_id must be provided.
* username: Returns membership records only for the specified user.
If the requesting user is not staff then only memberships for
teams associated with courses in which the requesting user is
enrolled are returned.
* team_id: Returns only membership records associated with the
specified team. The requesting user must be staff or enrolled in
the course associated with the team.
* page_size: Number of results to return per page.
* page: Page number to retrieve.
* expand: Comma separated list of types for which to return
expanded representations. Supports "user" and "team".
**Response Values for GET**
If the user is logged in and enrolled, the response contains:
* count: The total number of memberships 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 memberships matching the request.
* user: The user associated with the membership. This field may
contain an expanded or collapsed representation.
* team: The team associated with the membership. This field may
contain an expanded or collapsed representation.
* date_joined: The date and time the membership was created.
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 active, a 401 error is returned.
If neither team_id nor username are provided, a 400 error is
returned.
If team_id is provided but the team does not exist, a 404 error is
returned.
This endpoint uses 404 error codes to avoid leaking information
about team or user existence. Specifically, a 404 error will be
returned if a logged in user specifies a team_id for a course
they are not enrolled in.
Additionally, when username is specified the list of returned
memberships will be filtered to memberships in teams associated
with courses that the requesting user is enrolled in.
**Response Values for POST**
Any logged in user enrolled in a course can enroll themselves in a
team in the course. Course and global staff can enroll any user in
a team, with a few exceptions noted below.
If the user is not logged in and active, a 401 error is returned.
If username and team are not provided in the posted JSON, a 400
error is returned describing the missing fields.
If the specified team does not exist, a 404 error is returned.
If the user is not staff and is not enrolled in the course
associated with the team they are trying to join, or if they are
trying to add a user other than themselves to a team, a 404 error
is returned. This is to prevent leaking information about the
existence of teams and users.
If the specified user does not exist, a 404 error is returned.
If the user is already a member of a team in the course associated
with the team they are trying to join, a 400 error is returned.
This applies to both staff and students.
If the user is not enrolled in the course associated with the team
they are trying to join, a 400 error is returned. This can occur
when a staff user posts a request adding another user to a team.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
serializer_class = MembershipSerializer
paginate_by = 10
paginate_by_param = 'page_size'
pagination_serializer_class = PaginationSerializer
def get(self, request):
"""GET /api/team/v0/team_membership"""
queryset = CourseTeamMembership.objects.all()
specified_username_or_team = False
if 'team_id' in request.QUERY_PARAMS:
specified_username_or_team = True
team_id = request.QUERY_PARAMS['team_id']
try:
team = CourseTeam.objects.get(team_id=team_id)
except CourseTeam.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if not has_team_api_access(request.user, team.course_id):
return Response(status=status.HTTP_404_NOT_FOUND)
queryset = queryset.filter(team__team_id=team_id)
if 'username' in request.QUERY_PARAMS:
specified_username_or_team = True
if not request.user.is_staff:
enrolled_courses = (
CourseEnrollment.enrollments_for_user(request.user).values_list('course_id', flat=True)
)
staff_courses = (
CourseAccessRole.objects.filter(user=request.user, role='staff').values_list('course_id', flat=True)
)
valid_courses = [
CourseKey.from_string(course_key_string)
for course_list in [enrolled_courses, staff_courses]
for course_key_string in course_list
]
queryset = queryset.filter(team__course_id__in=valid_courses)
queryset = queryset.filter(user__username=request.QUERY_PARAMS['username'])
if not specified_username_or_team:
return Response(
build_api_error(ugettext_noop("username or team_id must be specified.")),
status=status.HTTP_400_BAD_REQUEST
)
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/team_membership"""
field_errors = {}
if 'username' not in request.DATA:
field_errors['username'] = build_api_error(ugettext_noop("Username is required."))
if 'team_id' not in request.DATA:
field_errors['team_id'] = build_api_error(ugettext_noop("Team id is required."))
if field_errors:
return Response({
'field_errors': field_errors,
}, status=status.HTTP_400_BAD_REQUEST)
try:
team = CourseTeam.objects.get(team_id=request.DATA['team_id'])
except CourseTeam.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
username = request.DATA['username']
if not has_team_api_access(request.user, team.course_id, access_username=username):
return Response(status=status.HTTP_404_NOT_FOUND)
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
membership = team.add_user(user)
except AlreadyOnTeamInCourse:
return Response(
build_api_error(
ugettext_noop("The user {username} is already a member of a team in this course."),
username=username
),
status=status.HTTP_400_BAD_REQUEST
)
except NotEnrolledInCourseForTeam:
return Response(
build_api_error(
ugettext_noop("The user {username} is not enrolled in the course associated with this team."),
username=username
),
status=status.HTTP_400_BAD_REQUEST
)
serializer = self.get_serializer(instance=membership)
return Response(serializer.data)
class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView):
"""
**Use Cases**
Gets individual course team memberships or removes a user from a course team.
**Example Requests**:
GET /api/team/v0/team_membership/{team_id},{username}
DELETE /api/team/v0/team_membership/{team_id},{username}
**Query Parameters for GET**
* expand: Comma separated list of types for which to return
expanded representations. Supports "user" and "team".
**Response Values for GET**
If the user is logged in and enrolled, or is course or global staff
the response contains:
* user: The user associated with the membership. This field may
contain an expanded or collapsed representation.
* team: The team associated with the membership. This field may
contain an expanded or collapsed representation.
* date_joined: The date and time the membership was created.
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 active, a 401 error is returned.
If specified team does not exist, a 404 error is returned.
If the user is logged in but is not enrolled in the course
associated with the specified team, or is not staff, a 404 error is
returned. This avoids leaking information about course or team
existence.
If the membership does not exist, a 404 error is returned.
**Response Values for DELETE**
Any logged in user enrolled in a course can remove themselves from
a team in the course. Course and global staff can remove any user
from a team. Successfully deleting a membership will return a 204
response with no content.
If the user is not logged in and active, a 401 error is returned.
If the specified team or username does not exist, a 404 error is
returned.
If the user is not staff and is attempting to remove another user
from a team, a 404 error is returned. This prevents leaking
information about team and user existence.
If the membership does not exist, a 404 error is returned.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
serializer_class = MembershipSerializer
def get_team(self, team_id):
"""Returns the team with team_id, or throws Http404 if it does not exist."""
try:
return CourseTeam.objects.get(team_id=team_id)
except CourseTeam.DoesNotExist:
raise Http404
def get_membership(self, username, team):
"""Returns the membership for the given user and team, or throws Http404 if it does not exist."""
try:
return CourseTeamMembership.objects.get(user__username=username, team=team)
except CourseTeamMembership.DoesNotExist:
raise Http404
def get(self, request, team_id, username):
"""GET /api/team/v0/team_membership/{team_id},{username}"""
team = self.get_team(team_id)
if not has_team_api_access(request.user, team.course_id):
return Response(status=status.HTTP_404_NOT_FOUND)
membership = self.get_membership(username, team)
serializer = self.get_serializer(instance=membership)
return Response(serializer.data)
def delete(self, request, team_id, username):
"""DELETE /api/team/v0/team_membership/{team_id},{username}"""
team = self.get_team(team_id)
if has_team_api_access(request.user, team.course_id, access_username=username):
membership = self.get_membership(username, team)
membership.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response(status=status.HTTP_404_NOT_FOUND)
......@@ -70,6 +70,16 @@ class DeveloperErrorViewMixin(object):
raise
class ExpandableFieldViewMixin(object):
"""A view mixin to add expansion information to the serializer context for later use by an ExpandableField."""
def get_serializer_context(self):
"""Adds expand information from query parameters to the serializer context to support expandable fields."""
result = super(ExpandableFieldViewMixin, self).get_serializer_context()
result['expand'] = [x for x in self.request.QUERY_PARAMS.get('expand', '').split(',') if x]
return result
def view_course_access(depth=0, access_action='load', check_for_milestones=False):
"""
Method decorator for an API endpoint that verifies the user has access to the course.
......@@ -142,6 +152,21 @@ def add_serializer_errors(serializer, data, field_errors):
return field_errors
def build_api_error(message, **kwargs):
"""Build an error dict corresponding to edX API conventions.
Args:
message (string): The string to use for developer and user messages.
The user message will be translated, but for this to work message
must have already been scraped. ugettext_noop is useful for this.
**kwargs: format parameters for message
"""
return {
'developer_message': message.format(**kwargs),
'user_message': _(message).format(**kwargs), # pylint: disable=translation-of-non-string
}
class RetrievePatchAPIView(RetrieveModelMixin, UpdateModelMixin, GenericAPIView):
"""Concrete view for retrieving and updating a model instance.
......
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