views.py 51.1 KB
Newer Older
1
"""HTTP endpoints for the Teams API."""
Diana Huang committed
2

3 4 5
import logging

from django.shortcuts import get_object_or_404, render_to_response
Diana Huang committed
6 7
from django.http import Http404
from django.conf import settings
8 9
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
10
from rest_framework.reverse import reverse
11
from rest_framework.views import APIView
12 13
from rest_framework.authentication import SessionAuthentication
from rest_framework_oauth.authentication import OAuth2Authentication
14 15
from rest_framework import status
from rest_framework import permissions
16 17
from django.db.models.signals import post_save
from django.dispatch import receiver
18
from django.contrib.auth.models import User
muhammad-ammar committed
19
from django_countries import countries
20 21 22 23
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.permissions import IsStaffOrReadOnly
24 25 26 27 28 29
from openedx.core.lib.api.view_utils import (
    RetrievePatchAPIView,
    add_serializer_errors,
    build_api_error,
    ExpandableFieldViewMixin
)
30
from openedx.core.lib.api.paginators import paginate_search_results, DefaultPagination
31 32 33 34
from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey

35 36 37
from courseware.courses import get_course_with_access, has_access
from student.models import CourseEnrollment, CourseAccessRole
from student.roles import CourseStaffRole
38
from django_comment_client.utils import has_discussion_privileges
39
from util.model_utils import truncate_fields
40 41
from . import is_feature_enabled
from lms.djangoapps.teams.models import CourseTeam, CourseTeamMembership
42 43 44 45
from .serializers import (
    CourseTeamSerializer,
    CourseTeamCreationSerializer,
    TopicSerializer,
46
    BulkTeamCountTopicSerializer,
Usman Khalid committed
47
    MembershipSerializer,
48
    add_team_count
49
)
50
from .search_indexes import CourseTeamIndexer
51
from .errors import AlreadyOnTeamInCourse, ElasticSearchConnectionError, NotEnrolledInCourseForTeam
52
from .utils import emit_team_event
Diana Huang committed
53

Usman Khalid committed
54
TEAM_MEMBERSHIPS_PER_PAGE = 2
55
TOPICS_PER_PAGE = 12
56
MAXIMUM_SEARCH_SIZE = 100000
57

58 59
log = logging.getLogger(__name__)

60

61 62 63 64 65 66 67 68 69 70 71 72
@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(unicode(changed_fields[field]), unicode(getattr(instance, field)))
                truncated_fields['team_id'] = instance.team_id
                truncated_fields['field'] = field

73
                emit_team_event(
74
                    'edx.team.changed',
75
                    instance.course_id,
76 77 78 79
                    truncated_fields
                )


80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
class TeamAPIPagination(DefaultPagination):
    """
    Pagination format used by the teams API.
    """
    page_size_query_param = "page_size"

    def get_paginated_response(self, data):
        """
        Annotate the response with pagination information.
        """
        response = super(TeamAPIPagination, self).get_paginated_response(data)

        # Add the current page to the response.
        # It may make sense to eventually move this field into the default
        # implementation, but for now, teams is the only API that uses this.
        response.data["current_page"] = self.page.number

        # This field can be derived from other fields in the response,
        # so it may make sense to have the JavaScript client calculate it
        # instead of including it in the response.
        response.data["start"] = (self.page.number - 1) * self.get_page_size(self.request)

        return response


class TopicsPagination(TeamAPIPagination):
    """Paginate topics. """
    page_size = TOPICS_PER_PAGE


110 111
class MyTeamsPagination(TeamAPIPagination):
    """Paginate the user's teams. """
112 113 114 115
    page_size = TEAM_MEMBERSHIPS_PER_PAGE


class TeamsDashboardView(GenericAPIView):
Diana Huang committed
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
    """
    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

137 138
        user = request.user

139 140
        # 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.
141
        sort_order = 'name'
142
        topics = get_alphabetical_topics(course)
143 144

        # Paginate and serialize topic data
145 146
        # BulkTeamCountPaginatedTopicSerializer will add team counts to the topics in a single
        # bulk operation per page.
147 148 149 150 151 152
        topics_data = self._serialize_and_paginate(
            TopicsPagination,
            topics,
            request,
            BulkTeamCountTopicSerializer,
            {'course_id': course.id},
153
        )
154 155
        topics_data["sort_order"] = sort_order

156 157
        user = request.user

158
        user_teams = CourseTeam.objects.filter(membership__user=user, course_id=course.id)
159 160 161
        user_teams_data = self._serialize_and_paginate(
            MyTeamsPagination,
            user_teams,
162
            request,
163 164
            CourseTeamSerializer,
            {'expand': ('user',)}
Usman Khalid committed
165 166
        )

167
        context = {
168
            "course": course,
169
            "topics": topics_data,
170 171 172
            # 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).
173 174 175
            "user_info": {
                "username": user.username,
                "privileged": has_discussion_privileges(user, course_key),
176
                "staff": bool(has_access(user, 'staff', course_key)),
177
                "teams": user_teams_data
178
            },
179 180 181 182
            "topic_url": reverse(
                'topics_detail', kwargs={'topic_id': 'topic_id', 'course_id': str(course_id)}, request=request
            ),
            "topics_url": reverse('topics_list', request=request),
muhammad-ammar committed
183
            "teams_url": reverse('teams_list', request=request),
184
            "teams_detail_url": reverse('teams_detail', args=['team_id']),
Usman Khalid committed
185
            "team_memberships_url": reverse('team_membership_list', request=request),
186
            "my_teams_url": reverse('teams_list', request=request),
187
            "team_membership_detail_url": reverse('team_membership_detail', args=['team_id', user.username]),
muhammad-ammar committed
188
            "languages": [[lang[0], _(lang[1])] for lang in settings.ALL_LANGUAGES],  # pylint: disable=translation-of-non-string
muhammad-ammar committed
189
            "countries": list(countries),
190
            "disable_courseware_js": True,
muzaffaryousaf committed
191
            "teams_base_url": reverse('teams_dashboard', request=request, kwargs={'course_id': course_id}),
192
        }
Diana Huang committed
193 194
        return render_to_response("teams/teams.html", context)

195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227
    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

Diana Huang committed
228

229
def has_team_api_access(user, course_key, access_username=None):
230 231
    """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,
232
    be course staff, be global staff, or have discussion privileges.
233 234 235 236

    Args:
      user (User): The user to check access for.
      course_key (CourseKey): The key to the course which we are checking access to.
237
      access_username (string): If provided, access_username must match user.username for non staff access.
238 239 240 241

    Returns:
      bool: True if the user has access, False otherwise.
    """
242 243 244 245
    if user.is_staff:
        return True
    if CourseStaffRole(course_key).has_user(user):
        return True
246 247
    if has_discussion_privileges(user, course_key):
        return True
248 249 250
    if not access_username or access_username == user.username:
        return CourseEnrollment.is_enrolled(user, course_key)
    return False
251 252


253
class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
    """
        **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.

273 274 275
            * 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
276
              with order_by.
277

278
            * order_by: Cannot be called along with with text_search. Must be one of the following:
279 280 281

                * name: Orders results by case insensitive team name (default).

282 283
                * open_slots: Orders results by most open slots (for tie-breaking,
                  last_activity_at is used, with most recent first).
284

285 286
                * last_activity_at: Orders result by team activity, with most active first
                  (for tie-breaking, open_slots is used, with most open slots first).
287

288 289
            * username: Return teams whose membership contains the given user.

290 291 292 293
            * page_size: Number of results to return per page.

            * page: Page number to retrieve.

294 295 296
            * expand: Comma separated list of types for which to return
              expanded representations. Supports "user" and "team".

297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314
        **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.

315 316 317
                * discussion_topic_id: The unique id of the comments service
                  discussion topic associated with this team.

318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334
                * 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.

335 336 337
                * last_activity_at: The date of the last activity of any team member
                  within the team.

338 339 340 341 342 343 344 345
                * 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.

346 347 348 349
            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.
350 351 352 353 354 355 356 357

            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.

358 359 360
            If the server is unable to connect to Elasticsearch, and
            the text_search parameter is supplied, a 503 error is returned.

361 362 363 364
        **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,
365 366
            but does not include the id, date_created, or membership fields.
            id is automatically computed based on name.
367

368 369
            If the user is not logged in, a 401 error is returned.

370 371 372
            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.
373 374 375 376 377 378 379

            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.
    """

380 381
    # OAuth2Authentication must come first to return a 401 for unauthenticated users
    authentication_classes = (OAuth2Authentication, SessionAuthentication)
382 383
    permission_classes = (permissions.IsAuthenticated,)
    serializer_class = CourseTeamSerializer
384
    pagination_class = TeamAPIPagination
385 386 387

    def get(self, request):
        """GET /api/team/v0/teams/"""
388
        result_filter = {}
389

390 391
        if 'course_id' in request.query_params:
            course_id_string = request.query_params['course_id']
392 393 394
            try:
                course_key = CourseKey.from_string(course_id_string)
                # Ensure the course exists
395 396
                course_module = modulestore().get_course(course_key)
                if course_module is None:
397 398 399
                    return Response(status=status.HTTP_404_NOT_FOUND)
                result_filter.update({'course_id': course_key})
            except InvalidKeyError:
400 401 402
                error = build_api_error(
                    ugettext_noop("The supplied course id {course_id} is not valid."),
                    course_id=course_id_string,
403
                )
404
                return Response(error, status=status.HTTP_400_BAD_REQUEST)
405 406 407 408

            if not has_team_api_access(request.user, course_key):
                return Response(status=status.HTTP_403_FORBIDDEN)
        else:
409 410 411 412
            return Response(
                build_api_error(ugettext_noop("course_id must be provided")),
                status=status.HTTP_400_BAD_REQUEST
            )
413

414 415
        text_search = request.query_params.get('text_search', None)
        if text_search and request.query_params.get('order_by', None):
416 417 418 419 420
            return Response(
                build_api_error(ugettext_noop("text_search and order_by cannot be provided together")),
                status=status.HTTP_400_BAD_REQUEST
            )

421 422 423
        username = request.query_params.get('username', None)
        if username is not None:
            result_filter.update({'membership__user__username': username})
424
        topic_id = request.query_params.get('topic_id', None)
425
        if topic_id is not None:
426 427 428 429 430 431
            if topic_id not in [topic['id'] for topic in course_module.teams_configuration['topics']]:
                error = build_api_error(
                    ugettext_noop('The supplied topic id {topic_id} is not valid'),
                    topic_id=topic_id
                )
                return Response(error, status=status.HTTP_400_BAD_REQUEST)
432
            result_filter.update({'topic_id': topic_id})
433
        if text_search and CourseTeamIndexer.search_is_enabled():
434 435 436 437 438 439 440 441
            try:
                search_engine = CourseTeamIndexer.engine()
            except ElasticSearchConnectionError:
                return Response(
                    build_api_error(ugettext_noop('Error connecting to elasticsearch')),
                    status=status.HTTP_503_SERVICE_UNAVAILABLE
                )

442 443 444
            result_filter.update({'course_id': course_id_string})

            search_results = search_engine.search(
445
                query_string=text_search,
446 447 448 449 450 451 452
                field_dictionary=result_filter,
                size=MAXIMUM_SEARCH_SIZE,
            )

            paginated_results = paginate_search_results(
                CourseTeam,
                search_results,
453
                self.paginator.get_page_size(request),
454
                self.get_page()
455
            )
456
            emit_team_event('edx.team.searched', course_key, {
457 458 459 460
                "number_of_results": search_results['total'],
                "search_text": text_search,
                "topic_id": topic_id,
            })
461 462 463 464

            page = self.paginate_queryset(paginated_results)
            serializer = self.get_serializer(page, many=True)
            order_by_input = None
465
        else:
466
            queryset = CourseTeam.objects.filter(**result_filter)
467
            order_by_input = request.query_params.get('order_by', 'name')
468
            if order_by_input == 'name':
469 470
                # MySQL does case-insensitive order_by.
                queryset = queryset.order_by('name')
471 472 473 474 475 476 477 478 479 480 481 482 483 484 485
            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': "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)
486
            serializer = self.get_serializer(page, many=True)
487

488 489 490 491
        response = self.get_paginated_response(serializer.data)
        if order_by_input is not None:
            response.data['sort_order'] = order_by_input
        return response
492 493 494 495 496 497

    def post(self, request):
        """POST /api/team/v0/teams/"""
        field_errors = {}
        course_key = None

498
        course_id = request.data.get('course_id')
499 500 501 502 503 504
        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:
505 506 507 508
            field_errors['course_id'] = build_api_error(
                ugettext_noop('The supplied course_id {course_id} is not valid.'),
                course_id=course_id
            )
509 510 511 512
            return Response({
                'field_errors': field_errors,
            }, status=status.HTTP_400_BAD_REQUEST)

513 514 515 516 517
        # 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):
518 519 520 521
            error_message = build_api_error(
                ugettext_noop('You are already in a team in this course.'),
                course_id=course_id
            )
522
            return Response(error_message, status=status.HTTP_400_BAD_REQUEST)
523 524 525 526

        if course_key and not has_team_api_access(request.user, course_key):
            return Response(status=status.HTTP_403_FORBIDDEN)

527
        data = request.data.copy()
528 529 530 531 532 533 534 535 536 537 538
        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()
539 540
            emit_team_event('edx.team.created', course_key, {
                'team_id': team.team_id
541
            })
542
            if not team_administrator:
543 544
                # Add the creating user to the team.
                team.add_user(request.user)
545
                emit_team_event(
546
                    'edx.team.learner_added',
547
                    course_key,
548 549 550 551 552 553
                    {
                        'team_id': team.team_id,
                        'user_id': request.user.id,
                        'add_method': 'added_on_create'
                    }
                )
554 555 556

            data = CourseTeamSerializer(team, context={"request": request}).data
            return Response(data)
557

558 559 560 561
    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
562
        page_kwarg = self.kwargs.get(self.paginator.page_query_param)
563
        page_query_param = self.request.query_params.get(self.paginator.page_query_param)
564 565
        return page_kwarg or page_query_param or 1

566

567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588
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)
        )


589
class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
590 591 592
    """
        **Use Cases**

593
            Get, update, or delete a course team's information. Updates are supported
594 595 596 597 598 599 600 601
            only through merge patch.

        **Example Requests**:

            GET /api/team/v0/teams/{team_id}}

            PATCH /api/team/v0/teams/{team_id} "application/merge-patch+json"

602 603
            DELETE /api/team/v0/teams/{team_id}

604 605 606 607 608
        **Query Parameters for GET**

            * expand: Comma separated list of types for which to return
              expanded representations. Supports "user" and "team".

609 610 611 612 613 614
        **Response Values for GET**

            If the user is logged in, the response contains the following fields:

                * id: The team's unique identifier.

615 616 617
                * discussion_topic_id: The unique id of the comments service
                  discussion topic associated with this team.

618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637
                * 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.

638 639 640
                * last_activity_at: The date of the last activity of any team member
                  within the team.

641 642 643 644 645
            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.

646 647 648
            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.
649 650 651 652 653 654 655

            If the specified team does not exist, a 404 error is returned.

        **Response Values for PATCH**

            Only staff can patch teams.

656 657
            If the user is anonymous or inactive, a 401 is returned.

658
            If the user is logged in and the team does not exist, a 404 is returned.
659 660
            If the user is not course or global staff, does not have discussion
            privileges, and the team does exist, a 403 is returned.
661 662 663 664 665 666 667

            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.
668 669 670 671 672 673 674 675 676 677 678 679 680 681

        **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.

682
    """
683
    authentication_classes = (OAuth2Authentication, SessionAuthentication)
684
    permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, IsEnrolledOrIsStaff,)
685 686 687 688 689 690 691 692
    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()

693 694 695 696
    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)
697 698 699
        # Note: list() forces the queryset to be evualuated before delete()
        memberships = list(CourseTeamMembership.get_memberships(team_id=team_id))

700 701 702
        # Note: also deletes all team memberships associated with this team
        team.delete()
        log.info('user %d deleted team %s', request.user.id, team_id)
703
        emit_team_event('edx.team.deleted', team.course_id, {
704 705 706
            'team_id': team_id,
        })
        for member in memberships:
707
            emit_team_event('edx.team.learner_removed', team.course_id, {
708 709 710 711
                'team_id': team_id,
                'remove_method': 'team_deleted',
                'user_id': member.user_id
            })
712 713
        return Response(status=status.HTTP_204_NO_CONTENT)

714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729

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).

730 731 732
            * 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'.
733 734 735 736 737 738 739

            * page_size: Number of results to return per page.

            * page: Page number to retrieve.

        **Response Values for GET**

740 741
            If the user is not logged in, a 401 error is returned.

742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772
            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.

    """

773
    authentication_classes = (OAuth2Authentication, SessionAuthentication)
774
    permission_classes = (permissions.IsAuthenticated,)
775
    pagination_class = TopicsPagination
776 777 778

    def get(self, request):
        """GET /api/team/v0/topics/?course_id={course_id}"""
779
        course_id_string = request.query_params.get('course_id', None)
780 781 782
        if course_id_string is None:
            return Response({
                'field_errors': {
783 784 785 786
                    'course_id': build_api_error(
                        ugettext_noop("The supplied course id {course_id} is not valid."),
                        course_id=course_id_string
                    )
787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802
                }
            }, 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)

803
        ordering = request.query_params.get('order_by', 'name')
804
        if ordering not in ['name', 'team_count']:
805
            return Response({
806 807 808 809 810 811
                'developer_message': "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),
812 813
            }, status=status.HTTP_400_BAD_REQUEST)

814 815 816 817 818 819 820
        # Always sort alphabetically, as it will be used as secondary sort
        # in the case of "team_count".
        topics = get_alphabetical_topics(course_module)
        if ordering == 'team_count':
            add_team_count(topics, course_id)
            topics.sort(key=lambda t: t['team_count'], reverse=True)
            page = self.paginate_queryset(topics)
821 822 823 824 825
            serializer = TopicSerializer(
                page,
                context={'course_id': course_id},
                many=True,
            )
826 827 828
        else:
            page = self.paginate_queryset(topics)
            # Use the serializer that adds team_count in a bulk operation per page.
829
            serializer = BulkTeamCountTopicSerializer(page, context={'course_id': course_id}, many=True)
830

831 832 833 834
        response = self.get_paginated_response(serializer.data)
        response.data['sort_order'] = ordering

        return response
835 836


837 838
def get_alphabetical_topics(course_module):
    """Return a list of team topics sorted alphabetically.
839 840 841 842 843 844 845

    Arguments:
        course_module (xmodule): the course which owns the team topics

    Returns:
        list: a list of sorted team topics
    """
846
    return sorted(course_module.teams_topics, key=lambda t: t['name'].lower())
847 848


849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867
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**

868 869
            If the user is not logged in, a 401 error is returned.

870 871 872
            If the topic_id course_id are not given or an unsupported value is
            passed for order_by, returns a 400 error.

873 874
            If the user is not enrolled in the course, or is not course or
            global staff, returns a 403 error.
875 876 877 878 879 880 881 882 883 884 885 886 887

            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.

    """

888
    authentication_classes = (OAuth2Authentication, SessionAuthentication)
889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910
    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)

911
        serializer = TopicSerializer(topics[0], context={'course_id': course_id})
912
        return Response(serializer.data)
913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939


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.

940 941 942 943
            * 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.

944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974
            * 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.

975 976 977
                * last_activity_at: The date of the last activity of the user
                  within the team.

978 979 980 981 982 983 984 985 986 987 988 989 990
            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.

991 992
            If the specified course_id is invalid, a 404 error is returned.

993 994 995 996 997 998 999 1000 1001
            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.

1002 1003 1004 1005 1006 1007 1008
            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.

1009 1010 1011
        **Response Values for POST**

            Any logged in user enrolled in a course can enroll themselves in a
1012 1013 1014
            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.
1015 1016 1017 1018 1019 1020 1021 1022

            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.

1023 1024 1025 1026 1027
            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.
1028 1029 1030 1031 1032 1033 1034 1035 1036

            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
1037 1038
            when a staff or discussion privileged user posts a request adding
            another user to a team.
1039 1040 1041 1042 1043 1044 1045 1046 1047
    """

    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
Usman Khalid committed
1048 1049
        username = None
        team_id = None
1050 1051 1052 1053
        requested_course_id = None
        requested_course_key = None
        accessible_course_ids = None

1054 1055
        if 'course_id' in request.query_params:
            requested_course_id = request.query_params['course_id']
1056 1057 1058 1059
            try:
                requested_course_key = CourseKey.from_string(requested_course_id)
            except InvalidKeyError:
                return Response(status=status.HTTP_404_NOT_FOUND)
1060

1061
        if 'team_id' in request.query_params:
1062
            specified_username_or_team = True
1063
            team_id = request.query_params['team_id']
1064 1065 1066 1067
            try:
                team = CourseTeam.objects.get(team_id=team_id)
            except CourseTeam.DoesNotExist:
                return Response(status=status.HTTP_404_NOT_FOUND)
1068 1069
            if requested_course_key is not None and requested_course_key != team.course_id:
                return Response(status=status.HTTP_400_BAD_REQUEST)
1070 1071 1072
            if not has_team_api_access(request.user, team.course_id):
                return Response(status=status.HTTP_404_NOT_FOUND)

1073
        if 'username' in request.query_params:
1074
            specified_username_or_team = True
1075
            username = request.query_params['username']
1076 1077 1078 1079 1080 1081 1082
            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)
                )
1083 1084 1085
                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_id not in accessible_course_ids:
                    return Response(status=status.HTTP_400_BAD_REQUEST)
1086 1087 1088 1089 1090 1091 1092

        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
            )

1093 1094 1095 1096 1097 1098 1099
        course_keys = None
        if requested_course_key is not None:
            course_keys = [requested_course_key]
        elif accessible_course_ids is not None:
            course_keys = [CourseKey.from_string(course_string) for course_string in accessible_course_ids]

        queryset = CourseTeamMembership.get_memberships(username, course_keys, team_id)
1100
        page = self.paginate_queryset(queryset)
1101 1102
        serializer = self.get_serializer(page, many=True)
        return self.get_paginated_response(serializer.data)
1103 1104 1105 1106 1107

    def post(self, request):
        """POST /api/team/v0/team_membership"""
        field_errors = {}

1108
        if 'username' not in request.data:
1109 1110
            field_errors['username'] = build_api_error(ugettext_noop("Username is required."))

1111
        if 'team_id' not in request.data:
1112 1113 1114 1115 1116 1117 1118 1119
            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:
1120
            team = CourseTeam.objects.get(team_id=request.data['team_id'])
1121 1122 1123
        except CourseTeam.DoesNotExist:
            return Response(status=status.HTTP_404_NOT_FOUND)

1124
        username = request.data['username']
1125 1126 1127 1128 1129 1130 1131 1132
        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)

1133 1134 1135 1136 1137 1138 1139
        course_module = modulestore().get_course(team.course_id)
        if course_module.teams_max_size is not None and team.users.count() >= course_module.teams_max_size:
            return Response(
                build_api_error(ugettext_noop("This team is already full.")),
                status=status.HTTP_400_BAD_REQUEST
            )

1140 1141
        try:
            membership = team.add_user(user)
1142
            emit_team_event(
1143
                'edx.team.learner_added',
1144
                team.course_id,
1145 1146 1147 1148 1149 1150
                {
                    'team_id': team.team_id,
                    'user_id': user.id,
                    'add_method': 'joined_from_team_view' if user == request.user else 'added_by_another_user'
                }
            )
1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201
        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.

1202 1203 1204
            * last_activity_at: The date of the last activity of any team member
                within the team.

1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223
            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
1224 1225 1226
            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.
1227 1228 1229 1230 1231 1232

            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.

1233 1234 1235 1236
            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.
1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275

            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)
1276
            removal_method = 'self_removal'
1277
            if 'admin' in request.query_params:
1278
                removal_method = 'removed_by_admin'
1279
            membership.delete()
1280
            emit_team_event(
1281
                'edx.team.learner_removed',
1282
                team.course_id,
1283 1284 1285
                {
                    'team_id': team.team_id,
                    'user_id': membership.user.id,
1286
                    'remove_method': removal_method
1287 1288
                }
            )
1289 1290 1291
            return Response(status=status.HTTP_204_NO_CONTENT)
        else:
            return Response(status=status.HTTP_404_NOT_FOUND)