This is needed so Masters learners will not be in the same team as verfied and audit learners. If the team is designated as organization protected, it will not be visible or accessible by verified or audit track learners MST-34
1328 lines
53 KiB
Python
1328 lines
53 KiB
Python
"""
|
|
HTTP endpoints for the Teams API.
|
|
"""
|
|
from __future__ import absolute_import, unicode_literals
|
|
|
|
import logging
|
|
|
|
import six
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import User
|
|
from django.db.models.signals import post_save
|
|
from django.dispatch import receiver
|
|
from django.http import Http404
|
|
from django.shortcuts import get_object_or_404, render_to_response
|
|
from django.utils.translation import ugettext as _
|
|
from django.utils.translation import ugettext_noop
|
|
from django_countries import countries
|
|
from edx_rest_framework_extensions.paginators import DefaultPagination, paginate_search_results
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from rest_framework import permissions, status
|
|
from rest_framework.authentication import SessionAuthentication
|
|
from rest_framework.generics import GenericAPIView
|
|
from rest_framework.response import Response
|
|
from rest_framework.reverse import reverse
|
|
from rest_framework.views import APIView
|
|
from rest_framework_oauth.authentication import OAuth2Authentication
|
|
|
|
from lms.djangoapps.courseware.courses import get_course_with_access, has_access
|
|
from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges
|
|
from lms.djangoapps.teams.api import (
|
|
OrganizationProtectionStatus,
|
|
has_team_api_access,
|
|
user_organization_protection_status,
|
|
has_specific_team_access,
|
|
add_team_count,
|
|
)
|
|
from lms.djangoapps.teams.models import CourseTeam, CourseTeamMembership
|
|
from openedx.core.lib.api.parsers import MergePatchParser
|
|
from openedx.core.lib.api.permissions import IsStaffOrReadOnly
|
|
from openedx.core.lib.api.view_utils import (
|
|
ExpandableFieldViewMixin,
|
|
RetrievePatchAPIView,
|
|
add_serializer_errors,
|
|
build_api_error
|
|
)
|
|
from student.models import CourseAccessRole, CourseEnrollment
|
|
from util.model_utils import truncate_fields
|
|
from xmodule.modulestore.django import modulestore
|
|
|
|
from . import is_feature_enabled
|
|
from .errors import AlreadyOnTeamInCourse, ElasticSearchConnectionError, NotEnrolledInCourseForTeam
|
|
from .search_indexes import CourseTeamIndexer
|
|
from .serializers import (
|
|
BulkTeamCountTopicSerializer,
|
|
CourseTeamCreationSerializer,
|
|
CourseTeamSerializer,
|
|
MembershipSerializer,
|
|
TopicSerializer
|
|
)
|
|
from .utils import emit_team_event
|
|
|
|
TEAM_MEMBERSHIPS_PER_PAGE = 2
|
|
TOPICS_PER_PAGE = 12
|
|
MAXIMUM_SEARCH_SIZE = 100000
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@receiver(post_save, sender=CourseTeam)
|
|
def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unused-argument
|
|
""" Emits signal after the team is saved. """
|
|
changed_fields = instance.field_tracker.changed()
|
|
# Don't emit events when we are first creating the team.
|
|
if not kwargs['created']:
|
|
for field in changed_fields:
|
|
if field not in instance.FIELD_BLACKLIST:
|
|
truncated_fields = truncate_fields(
|
|
six.text_type(changed_fields[field]),
|
|
six.text_type(getattr(instance, field))
|
|
)
|
|
truncated_fields['team_id'] = instance.team_id
|
|
truncated_fields['field'] = field
|
|
|
|
emit_team_event(
|
|
'edx.team.changed',
|
|
instance.course_id,
|
|
truncated_fields
|
|
)
|
|
|
|
|
|
class TopicsPagination(DefaultPagination):
|
|
"""Paginate topics. """
|
|
page_size = TOPICS_PER_PAGE
|
|
|
|
|
|
class MyTeamsPagination(DefaultPagination):
|
|
"""Paginate the user's teams. """
|
|
page_size = TEAM_MEMBERSHIPS_PER_PAGE
|
|
|
|
|
|
class TeamsDashboardView(GenericAPIView):
|
|
"""
|
|
View methods related to the teams dashboard.
|
|
"""
|
|
|
|
def get(self, request, course_id):
|
|
"""
|
|
Renders the teams dashboard, which is shown on the "Teams" tab.
|
|
|
|
Raises a 404 if the course specified by course_id does not exist, the
|
|
user is not registered for the course, or the teams feature is not enabled.
|
|
"""
|
|
course_key = CourseKey.from_string(course_id)
|
|
course = get_course_with_access(request.user, "load", course_key)
|
|
|
|
if not is_feature_enabled(course):
|
|
raise Http404
|
|
|
|
if not CourseEnrollment.is_enrolled(request.user, course.id) and \
|
|
not has_access(request.user, 'staff', course, course.id):
|
|
raise Http404
|
|
|
|
user = request.user
|
|
|
|
# Even though sorting is done outside of the serializer, sort_order needs to be passed
|
|
# to the serializer so that the paginated results indicate how they were sorted.
|
|
sort_order = 'name'
|
|
topics = get_alphabetical_topics(course)
|
|
organization_protection_status = user_organization_protection_status(request.user, course_key)
|
|
|
|
# Paginate and serialize topic data
|
|
# BulkTeamCountPaginatedTopicSerializer will add team counts to the topics in a single
|
|
# bulk operation per page.
|
|
topics_data = self._serialize_and_paginate(
|
|
TopicsPagination,
|
|
topics,
|
|
request,
|
|
BulkTeamCountTopicSerializer,
|
|
{
|
|
'course_id': course.id,
|
|
'organization_protection_status': organization_protection_status
|
|
},
|
|
)
|
|
topics_data["sort_order"] = sort_order
|
|
|
|
filter_query = {
|
|
'membership__user': user,
|
|
'course_id': course.id,
|
|
}
|
|
if organization_protection_status != OrganizationProtectionStatus.protection_exempt:
|
|
is_user_org_protected = organization_protection_status == OrganizationProtectionStatus.protected
|
|
filter_query['organization_protected'] = is_user_org_protected
|
|
|
|
user_teams = CourseTeam.objects.filter(**filter_query)
|
|
user_teams_data = self._serialize_and_paginate(
|
|
MyTeamsPagination,
|
|
user_teams,
|
|
request,
|
|
CourseTeamSerializer,
|
|
{'expand': ('user',)}
|
|
)
|
|
|
|
context = {
|
|
"course": course,
|
|
"topics": topics_data,
|
|
# It is necessary to pass both privileged and staff because only privileged users can
|
|
# administer discussion threads, but both privileged and staff users are allowed to create
|
|
# multiple teams (since they are not automatically added to teams upon creation).
|
|
"user_info": {
|
|
"username": user.username,
|
|
"privileged": has_discussion_privileges(user, course_key),
|
|
"staff": bool(has_access(user, 'staff', course_key)),
|
|
"teams": user_teams_data
|
|
},
|
|
"topic_url": reverse(
|
|
'topics_detail', kwargs={'topic_id': 'topic_id', 'course_id': str(course_id)}, request=request
|
|
),
|
|
"topics_url": reverse('topics_list', request=request),
|
|
"teams_url": reverse('teams_list', request=request),
|
|
"teams_detail_url": reverse('teams_detail', args=['team_id']),
|
|
"team_memberships_url": reverse('team_membership_list', request=request),
|
|
"my_teams_url": reverse('teams_list', request=request),
|
|
"team_membership_detail_url": reverse('team_membership_detail', args=['team_id', user.username]),
|
|
"languages": [[lang[0], _(lang[1])] for lang in settings.ALL_LANGUAGES], # pylint: disable=translation-of-non-string
|
|
"countries": list(countries),
|
|
"disable_courseware_js": True,
|
|
"teams_base_url": reverse('teams_dashboard', request=request, kwargs={'course_id': course_id}),
|
|
}
|
|
return render_to_response("teams/teams.html", context)
|
|
|
|
def _serialize_and_paginate(self, pagination_cls, queryset, request, serializer_cls, serializer_ctx):
|
|
"""
|
|
Serialize and paginate objects in a queryset.
|
|
|
|
Arguments:
|
|
pagination_cls (pagination.Paginator class): Django Rest Framework Paginator subclass.
|
|
queryset (QuerySet): Django queryset to serialize/paginate.
|
|
serializer_cls (serializers.Serializer class): Django Rest Framework Serializer subclass.
|
|
serializer_ctx (dict): Context dictionary to pass to the serializer
|
|
|
|
Returns: dict
|
|
|
|
"""
|
|
# Django Rest Framework v3 requires that we pass the request
|
|
# into the serializer's context if the serialize contains
|
|
# hyperlink fields.
|
|
serializer_ctx["request"] = request
|
|
|
|
# Instantiate the paginator and use it to paginate the queryset
|
|
paginator = pagination_cls()
|
|
page = paginator.paginate_queryset(queryset, request)
|
|
|
|
# Serialize the page
|
|
serializer = serializer_cls(page, context=serializer_ctx, many=True)
|
|
|
|
# Use the paginator to construct the response data
|
|
# This will use the pagination subclass for the view to add additional
|
|
# fields to the response.
|
|
# For example, if the input data is a list, the output data would
|
|
# be a dictionary with keys "count", "next", "previous", and "results"
|
|
# (where "results" is set to the value of the original list)
|
|
return paginator.get_paginated_response(serializer.data).data
|
|
|
|
|
|
class TeamsListView(ExpandableFieldViewMixin, 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: Searches for full word matches on the name, description,
|
|
country, and language fields. NOTES: Search is on full names for countries
|
|
and languages, not the ISO codes. Text_search cannot be requested along with
|
|
with order_by.
|
|
|
|
* order_by: Cannot be called along with with text_search. Must be one of the following:
|
|
|
|
* name: Orders results by case insensitive team name (default).
|
|
|
|
* open_slots: Orders results by most open slots (for tie-breaking,
|
|
last_activity_at is used, with most recent first).
|
|
|
|
* last_activity_at: Orders result by team activity, with most active first
|
|
(for tie-breaking, open_slots is used, with most open slots first).
|
|
|
|
* username: Return teams whose membership contains the given user.
|
|
|
|
* 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 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.
|
|
|
|
* discussion_topic_id: The unique id of the comments service
|
|
discussion topic associated with this team.
|
|
|
|
* name: The name of the team.
|
|
|
|
* 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.
|
|
|
|
* last_activity_at: The date of the last activity of any team member
|
|
within the team.
|
|
|
|
* membership: A list of the users that are members of the team.
|
|
See membership endpoint for more detail.
|
|
|
|
* organization_protected: Whether the team consists of organization-protected
|
|
learners
|
|
|
|
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, 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.
|
|
|
|
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.
|
|
|
|
If the server is unable to connect to Elasticsearch, and
|
|
the text_search parameter is supplied, a 503 error is returned.
|
|
|
|
If the requesting user is a learner, the learner would only see organization
|
|
protected set of teams if the learner is enrolled in a degree bearing institution.
|
|
Otherwise, the learner will only see organization unprotected set of teams.
|
|
|
|
**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, date_created, or membership fields.
|
|
id is automatically computed based on name.
|
|
|
|
If the user is not logged in, a 401 error is returned.
|
|
|
|
If the user is not enrolled in the course, is not course or
|
|
global staff, or does not have discussion privileges 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.
|
|
"""
|
|
|
|
# OAuth2Authentication must come first to return a 401 for unauthenticated users
|
|
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = CourseTeamSerializer
|
|
|
|
def get(self, request):
|
|
"""GET /api/team/v0/teams/"""
|
|
result_filter = {}
|
|
|
|
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
|
|
course_module = modulestore().get_course(course_key)
|
|
if course_module is None:
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
result_filter.update({'course_id': course_key})
|
|
except InvalidKeyError:
|
|
error = build_api_error(
|
|
ugettext_noop(u"The supplied course id {course_id} is not valid."),
|
|
course_id=course_id_string,
|
|
)
|
|
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:
|
|
return Response(
|
|
build_api_error(ugettext_noop("course_id must be provided")),
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
text_search = request.query_params.get('text_search', None)
|
|
if text_search and request.query_params.get('order_by', None):
|
|
return Response(
|
|
build_api_error(ugettext_noop("text_search and order_by cannot be provided together")),
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
username = request.query_params.get('username', None)
|
|
if username is not None:
|
|
result_filter.update({'membership__user__username': username})
|
|
topic_id = request.query_params.get('topic_id', None)
|
|
if topic_id is not None:
|
|
if topic_id not in course_module.teamsets_by_id:
|
|
error = build_api_error(
|
|
ugettext_noop(u'The supplied topic id {topic_id} is not valid'),
|
|
topic_id=topic_id
|
|
)
|
|
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
|
result_filter.update({'topic_id': topic_id})
|
|
|
|
organization_protection_status = user_organization_protection_status(request.user, course_key)
|
|
if organization_protection_status != OrganizationProtectionStatus.protection_exempt:
|
|
result_filter.update(
|
|
{
|
|
'organization_protected': organization_protection_status == OrganizationProtectionStatus.protected
|
|
}
|
|
)
|
|
|
|
if text_search and CourseTeamIndexer.search_is_enabled():
|
|
try:
|
|
search_engine = CourseTeamIndexer.engine()
|
|
except ElasticSearchConnectionError:
|
|
return Response(
|
|
build_api_error(ugettext_noop('Error connecting to elasticsearch')),
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
|
)
|
|
|
|
result_filter.update({'course_id': course_id_string})
|
|
|
|
search_results = search_engine.search(
|
|
query_string=text_search,
|
|
field_dictionary=result_filter,
|
|
size=MAXIMUM_SEARCH_SIZE,
|
|
)
|
|
|
|
paginated_results = paginate_search_results(
|
|
CourseTeam,
|
|
search_results,
|
|
self.paginator.get_page_size(request),
|
|
self.get_page()
|
|
)
|
|
emit_team_event('edx.team.searched', course_key, {
|
|
"number_of_results": search_results['total'],
|
|
"search_text": text_search,
|
|
"topic_id": topic_id,
|
|
})
|
|
|
|
page = self.paginate_queryset(paginated_results)
|
|
serializer = self.get_serializer(page, many=True)
|
|
order_by_input = None
|
|
else:
|
|
queryset = CourseTeam.objects.filter(**result_filter)
|
|
order_by_input = request.query_params.get('order_by', 'name')
|
|
if order_by_input == 'name':
|
|
# MySQL does case-insensitive order_by.
|
|
queryset = queryset.order_by('name')
|
|
elif order_by_input == 'open_slots':
|
|
queryset = queryset.order_by('team_size', '-last_activity_at')
|
|
elif order_by_input == 'last_activity_at':
|
|
queryset = queryset.order_by('-last_activity_at', 'team_size')
|
|
else:
|
|
return Response({
|
|
'developer_message': u"unsupported order_by value {ordering}".format(ordering=order_by_input),
|
|
# Translators: 'ordering' is a string describing a way
|
|
# of ordering a list. For example, {ordering} may be
|
|
# 'name', indicating that the user wants to sort the
|
|
# list by lower case name.
|
|
'user_message': _(u"The ordering {ordering} is not supported").format(ordering=order_by_input),
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
page = self.paginate_queryset(queryset)
|
|
serializer = self.get_serializer(page, many=True)
|
|
|
|
response = self.get_paginated_response(serializer.data)
|
|
if order_by_input is not None:
|
|
response.data['sort_order'] = order_by_input
|
|
return response
|
|
|
|
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'] = build_api_error(
|
|
ugettext_noop(u'The supplied course_id {course_id} is not valid.'),
|
|
course_id=course_id
|
|
)
|
|
return Response({
|
|
'field_errors': field_errors,
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Course and global staff, as well as discussion "privileged" users, will not automatically
|
|
# be added to a team when they create it. They are allowed to create multiple teams.
|
|
team_administrator = (has_access(request.user, 'staff', course_key)
|
|
or has_discussion_privileges(request.user, course_key))
|
|
if not team_administrator and CourseTeamMembership.user_in_team_for_course(request.user, course_key):
|
|
error_message = build_api_error(
|
|
ugettext_noop('You are already in a team in this course.'),
|
|
course_id=course_id
|
|
)
|
|
return Response(error_message, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if course_key and not has_team_api_access(request.user, course_key):
|
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
|
|
data = request.data.copy()
|
|
data['course_id'] = six.text_type(course_key)
|
|
|
|
organization_protection_status = user_organization_protection_status(request.user, course_key)
|
|
if organization_protection_status != OrganizationProtectionStatus.protection_exempt:
|
|
data['organization_protected'] = organization_protection_status == OrganizationProtectionStatus.protected
|
|
|
|
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()
|
|
emit_team_event('edx.team.created', course_key, {
|
|
'team_id': team.team_id
|
|
})
|
|
if not team_administrator:
|
|
# Add the creating user to the team.
|
|
team.add_user(request.user)
|
|
emit_team_event(
|
|
'edx.team.learner_added',
|
|
course_key,
|
|
{
|
|
'team_id': team.team_id,
|
|
'user_id': request.user.id,
|
|
'add_method': 'added_on_create'
|
|
}
|
|
)
|
|
|
|
data = CourseTeamSerializer(team, context={"request": request}).data
|
|
return Response(data)
|
|
|
|
def get_page(self):
|
|
""" Returns page number specified in args, params, or defaults to 1. """
|
|
# This code is taken from within the GenericAPIView#paginate_queryset method.
|
|
# We need need access to the page outside of that method for our paginate_search_results method
|
|
page_kwarg = self.kwargs.get(self.paginator.page_query_param)
|
|
page_query_param = self.request.query_params.get(self.paginator.page_query_param)
|
|
return page_kwarg or page_query_param or 1
|
|
|
|
|
|
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)
|
|
|
|
|
|
class IsStaffOrPrivilegedOrReadOnly(IsStaffOrReadOnly):
|
|
"""
|
|
Permission that checks to see if the user is global staff, course
|
|
staff, or has discussion privileges. If none of those conditions are
|
|
met, only read access will be granted.
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
return (
|
|
has_discussion_privileges(request.user, obj.course_id) or
|
|
super(IsStaffOrPrivilegedOrReadOnly, self).has_object_permission(request, view, obj)
|
|
)
|
|
|
|
|
|
class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
|
|
"""
|
|
**Use Cases**
|
|
|
|
Get, update, or delete 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"
|
|
|
|
DELETE /api/team/v0/teams/{team_id}
|
|
|
|
**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:
|
|
|
|
* id: The team's unique identifier.
|
|
|
|
* discussion_topic_id: The unique id of the comments service
|
|
discussion topic associated with this team.
|
|
|
|
* name: The name of the team.
|
|
|
|
* 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.
|
|
|
|
* last_activity_at: The date of the last activity of any team member
|
|
within the team.
|
|
|
|
* organization_protected: Whether the team consists of organization-protected
|
|
learners
|
|
|
|
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, 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.
|
|
|
|
**Response Values for PATCH**
|
|
|
|
Only staff can patch teams.
|
|
|
|
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, does not have discussion
|
|
privileges, 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.
|
|
|
|
**Response Values for DELETE**
|
|
|
|
Only staff can delete teams. When a team is deleted, all
|
|
team memberships associated with that team are also
|
|
deleted. Returns 204 on successful deletion.
|
|
|
|
If the user is anonymous or inactive, a 401 is returned.
|
|
|
|
If the user is not course or global staff and does not
|
|
have discussion privileges, a 403 is returned.
|
|
|
|
If the user is logged in and the team does not exist, a 404 is returned.
|
|
|
|
"""
|
|
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
|
permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, 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()
|
|
|
|
def delete(self, request, team_id):
|
|
"""DELETE /api/team/v0/teams/{team_id}"""
|
|
team = get_object_or_404(CourseTeam, team_id=team_id)
|
|
self.check_object_permissions(request, team)
|
|
# Note: list() forces the queryset to be evualuated before delete()
|
|
memberships = list(CourseTeamMembership.get_memberships(team_id=team_id))
|
|
|
|
# Note: also deletes all team memberships associated with this team
|
|
team.delete()
|
|
log.info(u'user %d deleted team %s', request.user.id, team_id)
|
|
emit_team_event('edx.team.deleted', team.course_id, {
|
|
'team_id': team_id,
|
|
})
|
|
for member in memberships:
|
|
emit_team_event('edx.team.learner_removed', team.course_id, {
|
|
'team_id': team_id,
|
|
'remove_method': 'team_deleted',
|
|
'user_id': member.user_id
|
|
})
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
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' and 'team_count' are supported;
|
|
the default value is 'name'. If 'team_count' is specified, topics are returned first sorted
|
|
by number of teams per topic (descending), with a secondary sort of 'name'.
|
|
|
|
* page_size: Number of results to return per page.
|
|
|
|
* page: Page number to retrieve.
|
|
|
|
**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.
|
|
|
|
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.
|
|
|
|
* team_count: Number of teams created under the topic. If the requesting user
|
|
is enrolled into a degree bearing institution, the count only include the teams
|
|
with organization_protected attribute true. If the requesting user is not
|
|
affiliated with any institutions, the teams included in the count would only be
|
|
those teams whose members are outside of institutions affliation.
|
|
"""
|
|
|
|
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
pagination_class = TopicsPagination
|
|
|
|
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': build_api_error(
|
|
ugettext_noop(u"The supplied course id {course_id} is not valid."),
|
|
course_id=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)
|
|
|
|
ordering = request.query_params.get('order_by', 'name')
|
|
if ordering not in ['name', 'team_count']:
|
|
return Response({
|
|
'developer_message': u"unsupported order_by value {ordering}".format(ordering=ordering),
|
|
# Translators: 'ordering' is a string describing a way
|
|
# of ordering a list. For example, {ordering} may be
|
|
# 'name', indicating that the user wants to sort the
|
|
# list by lower case name.
|
|
'user_message': _(u"The ordering {ordering} is not supported").format(ordering=ordering),
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Always sort alphabetically, as it will be used as secondary sort
|
|
# in the case of "team_count".
|
|
organization_protection_status = user_organization_protection_status(request.user, course_id)
|
|
topics = get_alphabetical_topics(course_module)
|
|
if ordering == 'team_count':
|
|
add_team_count(topics, course_id, organization_protection_status)
|
|
topics.sort(key=lambda t: t['team_count'], reverse=True)
|
|
page = self.paginate_queryset(topics)
|
|
serializer = TopicSerializer(
|
|
page,
|
|
context={'course_id': course_id},
|
|
many=True,
|
|
)
|
|
else:
|
|
page = self.paginate_queryset(topics)
|
|
# Use the serializer that adds team_count in a bulk operation per page.
|
|
serializer = BulkTeamCountTopicSerializer(
|
|
page,
|
|
context={
|
|
'course_id': course_id,
|
|
'organization_protection_status': organization_protection_status
|
|
},
|
|
many=True
|
|
)
|
|
|
|
response = self.get_paginated_response(serializer.data)
|
|
response.data['sort_order'] = ordering
|
|
|
|
return response
|
|
|
|
|
|
def get_alphabetical_topics(course_module):
|
|
"""Return a list of team topics sorted alphabetically.
|
|
|
|
Arguments:
|
|
course_module (xmodule): the course which owns the team topics
|
|
|
|
Returns:
|
|
list: a list of sorted team topics
|
|
"""
|
|
return sorted(
|
|
course_module.teams_configuration.cleaned_data_old_format['topics'],
|
|
key=lambda t: t['name'].lower(),
|
|
)
|
|
|
|
|
|
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 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 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.
|
|
|
|
* team_count: Number of teams created under the topic. If the requesting user
|
|
is enrolled into a degree bearing institution, the count only include the teams
|
|
with organization_protected attribute true. If the requesting user is not
|
|
affiliated with any institutions, the teams included in the count would only be
|
|
those teams whose members are outside of institutions affliation.
|
|
"""
|
|
|
|
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
|
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)
|
|
|
|
try:
|
|
topic = course_module.teamsets_by_id[topic_id]
|
|
except KeyError:
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
|
|
organization_protection_status = user_organization_protection_status(request.user, course_id)
|
|
serializer = TopicSerializer(
|
|
topic.cleaned_data_old_format,
|
|
context={
|
|
'course_id': course_id,
|
|
'organization_protection_status': organization_protection_status
|
|
}
|
|
)
|
|
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.
|
|
|
|
* course_id: Returns membership records only for the specified
|
|
course. Username must have access to this course, or else team_id
|
|
must be in this course.
|
|
|
|
* 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.
|
|
|
|
* last_activity_at: The date of the last activity of the user
|
|
within the team.
|
|
|
|
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.
|
|
|
|
If the specified course_id is invalid, 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.
|
|
|
|
If the course specified by course_id does not contain the team
|
|
specified by team_id, a 400 error is returned.
|
|
|
|
If the user is not enrolled in the course specified by course_id,
|
|
and does not have staff access to the course, a 400 error is
|
|
returned.
|
|
|
|
**Response Values for POST**
|
|
|
|
Any logged in user enrolled in a course can enroll themselves in a
|
|
team in the course. Course staff, global staff, and discussion
|
|
privileged users 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, does not have discussion privileges,
|
|
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 or discussion privileged user posts a request adding
|
|
another user to a team.
|
|
"""
|
|
|
|
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = MembershipSerializer
|
|
|
|
def get(self, request):
|
|
"""GET /api/team/v0/team_membership"""
|
|
specified_username_or_team = False
|
|
username = None
|
|
team_id = None
|
|
requested_course_id = None
|
|
requested_course_key = None
|
|
accessible_course_ids = None
|
|
|
|
if 'course_id' in request.query_params:
|
|
requested_course_id = request.query_params['course_id']
|
|
try:
|
|
requested_course_key = CourseKey.from_string(requested_course_id)
|
|
except InvalidKeyError:
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
|
|
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 requested_course_key is not None and requested_course_key != team.course_id:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
if not has_team_api_access(request.user, team.course_id):
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
if not has_specific_team_access(request.user, team):
|
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
|
|
if 'username' in request.query_params:
|
|
specified_username_or_team = True
|
|
username = request.query_params['username']
|
|
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)
|
|
)
|
|
accessible_course_ids = [item for sublist in (enrolled_courses, staff_courses) for item in sublist]
|
|
if requested_course_id is not None and requested_course_key not in accessible_course_ids:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
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
|
|
)
|
|
|
|
course_keys = None
|
|
if requested_course_key is not None:
|
|
course_keys = [requested_course_key]
|
|
elif accessible_course_ids is not None:
|
|
course_keys = accessible_course_ids
|
|
|
|
queryset = CourseTeamMembership.get_memberships(username, course_keys, team_id)
|
|
page = self.paginate_queryset(queryset)
|
|
serializer = self.get_serializer(page, many=True)
|
|
return self.get_paginated_response(serializer.data)
|
|
|
|
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)
|
|
|
|
if not has_specific_team_access(request.user, team):
|
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
|
|
try:
|
|
user = User.objects.get(username=username)
|
|
except User.DoesNotExist:
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
|
|
course_module = modulestore().get_course(team.course_id)
|
|
# This should use `calc_max_team_size` instead of `default_max_team_size` (TODO MST-32).
|
|
max_team_size = course_module.teams_configuration.default_max_team_size
|
|
if max_team_size is not None and team.users.count() >= max_team_size:
|
|
return Response(
|
|
build_api_error(ugettext_noop("This team is already full.")),
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
membership = team.add_user(user)
|
|
emit_team_event(
|
|
'edx.team.learner_added',
|
|
team.course_id,
|
|
{
|
|
'team_id': team.team_id,
|
|
'user_id': user.id,
|
|
'add_method': 'joined_from_team_view' if user == request.user else 'added_by_another_user'
|
|
}
|
|
)
|
|
except AlreadyOnTeamInCourse:
|
|
return Response(
|
|
build_api_error(
|
|
ugettext_noop(u"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(u"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.
|
|
|
|
* last_activity_at: The date of the last activity of any team member
|
|
within the team.
|
|
|
|
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 staff, global staff, and discussion
|
|
privileged users 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 or a discussion privileged user 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)
|
|
|
|
if not has_specific_team_access(request.user, team):
|
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
|
|
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 not has_team_api_access(request.user, team.course_id, access_username=username):
|
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
|
|
if not has_specific_team_access(request.user, team):
|
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
|
|
membership = self.get_membership(username, team)
|
|
removal_method = 'self_removal'
|
|
if 'admin' in request.query_params:
|
|
removal_method = 'removed_by_admin'
|
|
membership.delete()
|
|
emit_team_event(
|
|
'edx.team.learner_removed',
|
|
team.course_id,
|
|
{
|
|
'team_id': team.team_id,
|
|
'user_id': membership.user.id,
|
|
'remove_method': removal_method
|
|
}
|
|
)
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|