Commit 1bd4b1d6 by cahrens

Add sorting by team_count, as well as secondary sort.

TNL-3012
parent 9e311931
...@@ -139,41 +139,60 @@ class BaseTopicSerializer(serializers.Serializer): ...@@ -139,41 +139,60 @@ class BaseTopicSerializer(serializers.Serializer):
class TopicSerializer(BaseTopicSerializer): class TopicSerializer(BaseTopicSerializer):
""" """
Adds team_count to the basic topic serializer. Use only when Adds team_count to the basic topic serializer, checking if team_count
serializing a single topic. When serializing many topics, use is already present in the topic data, and if not, querying the CourseTeam
`PaginatedTopicSerializer` to avoid O(N) SQL queries. Requires model to get the count. Requires that `context` is provided with a valid course_id
that `context` is provided with a valid course_id in order to in order to filter teams within the course.
filter teams within the course.
""" """
team_count = serializers.SerializerMethodField('get_team_count') team_count = serializers.SerializerMethodField('get_team_count')
def get_team_count(self, topic): def get_team_count(self, topic):
"""Get the number of teams associated with this topic""" """Get the number of teams associated with this topic"""
return CourseTeam.objects.filter(course_id=self.context['course_id'], topic_id=topic['id']).count() # If team_count is already present (possible if topic data was pre-processed for sorting), return it.
if 'team_count' in topic:
return topic['team_count']
else:
return CourseTeam.objects.filter(course_id=self.context['course_id'], topic_id=topic['id']).count()
class PaginatedTopicSerializer(PaginationSerializer): class PaginatedTopicSerializer(PaginationSerializer):
""" """
Serializes a set of topics, adding team_count field to each topic. Serializes a set of topics, adding the team_count field to each topic individually, if team_count
Requires that `context` is provided with a valid course_id in is not already present in the topic data. Requires that `context` is provided with a valid course_id in
order to filter teams within the course. order to filter teams within the course.
""" """
class Meta(object): class Meta(object):
"""Defines meta information for the PaginatedTopicSerializer.""" """Defines meta information for the PaginatedTopicSerializer."""
object_serializer_class = TopicSerializer
class BulkTeamCountPaginatedTopicSerializer(PaginationSerializer):
"""
Serializes a set of topics, adding the team_count field to each topic as a bulk operation per page
(only on the page being returned). Requires that `context` is provided with a valid course_id in
order to filter teams within the course.
"""
class Meta(object):
"""Defines meta information for the BulkTeamCountPaginatedTopicSerializer."""
object_serializer_class = BaseTopicSerializer object_serializer_class = BaseTopicSerializer
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Adds team_count to each topic.""" """Adds team_count to each topic on the current page."""
super(PaginatedTopicSerializer, self).__init__(*args, **kwargs) super(BulkTeamCountPaginatedTopicSerializer, self).__init__(*args, **kwargs)
add_team_count(self.data['results'], self.context['course_id'])
# The following query gets all the team_counts for each topic
# and outputs the result as a list of dicts (one per topic).
topic_ids = [topic['id'] for topic in self.data['results']] def add_team_count(topics, course_id):
teams_per_topic = CourseTeam.objects.filter( """
course_id=self.context['course_id'], Helper method to add team_count for a list of topics.
topic_id__in=topic_ids This allows for a more efficient single query.
).values('topic_id').annotate(team_count=Count('topic_id')) """
topic_ids = [topic['id'] for topic in topics]
topics_to_team_count = {d['topic_id']: d['team_count'] for d in teams_per_topic} teams_per_topic = CourseTeam.objects.filter(
for topic in self.data['results']: course_id=course_id,
topic['team_count'] = topics_to_team_count.get(topic['id'], 0) topic_id__in=topic_ids
).values('topic_id').annotate(team_count=Count('topic_id'))
topics_to_team_count = {d['topic_id']: d['team_count'] for d in teams_per_topic}
for topic in topics:
topic['team_count'] = topics_to_team_count.get(topic['id'], 0)
...@@ -8,7 +8,10 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -8,7 +8,10 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from lms.djangoapps.teams.tests.factories import CourseTeamFactory from lms.djangoapps.teams.tests.factories import CourseTeamFactory
from lms.djangoapps.teams.serializers import BaseTopicSerializer, PaginatedTopicSerializer, TopicSerializer from lms.djangoapps.teams.serializers import (
BaseTopicSerializer, PaginatedTopicSerializer, BulkTeamCountPaginatedTopicSerializer,
TopicSerializer, add_team_count
)
class TopicTestCase(ModuleStoreTestCase): class TopicTestCase(ModuleStoreTestCase):
...@@ -92,12 +95,14 @@ class TopicSerializerTestCase(TopicTestCase): ...@@ -92,12 +95,14 @@ class TopicSerializerTestCase(TopicTestCase):
) )
class PaginatedTopicSerializerTestCase(TopicTestCase): class BasePaginatedTopicSerializerTestCase(TopicTestCase):
""" """
Tests for the `PaginatedTopicSerializer`, which should serialize team count Base class for testing the two paginated topic serializers.
data for many topics with constant time SQL queries.
""" """
__test__ = False
PAGE_SIZE = 5 PAGE_SIZE = 5
# Extending test classes should specify their serializer class.
serializer = None
def _merge_dicts(self, first, second): def _merge_dicts(self, first, second):
"""Convenience method to merge two dicts in a single expression""" """Convenience method to merge two dicts in a single expression"""
...@@ -128,7 +133,8 @@ class PaginatedTopicSerializerTestCase(TopicTestCase): ...@@ -128,7 +133,8 @@ class PaginatedTopicSerializerTestCase(TopicTestCase):
""" """
with self.assertNumQueries(num_queries): with self.assertNumQueries(num_queries):
page = Paginator(self.course.teams_topics, self.PAGE_SIZE).page(1) page = Paginator(self.course.teams_topics, self.PAGE_SIZE).page(1)
serializer = PaginatedTopicSerializer(instance=page, context={'course_id': self.course.id}) # pylint: disable=not-callable
serializer = self.serializer(instance=page, context={'course_id': self.course.id})
self.assertEqual( self.assertEqual(
serializer.data['results'], serializer.data['results'],
[self._merge_dicts(topic, {u'team_count': num_teams_per_topic}) for topic in topics] [self._merge_dicts(topic, {u'team_count': num_teams_per_topic}) for topic in topics]
...@@ -142,6 +148,15 @@ class PaginatedTopicSerializerTestCase(TopicTestCase): ...@@ -142,6 +148,15 @@ class PaginatedTopicSerializerTestCase(TopicTestCase):
self.course.teams_configuration['topics'] = [] self.course.teams_configuration['topics'] = []
self.assert_serializer_output([], num_teams_per_topic=0, num_queries=0) self.assert_serializer_output([], num_teams_per_topic=0, num_queries=0)
class BulkTeamCountPaginatedTopicSerializerTestCase(BasePaginatedTopicSerializerTestCase):
"""
Tests for the `BulkTeamCountPaginatedTopicSerializer`, which should serialize team_count
data for many topics with constant time SQL queries.
"""
__test__ = True
serializer = BulkTeamCountPaginatedTopicSerializer
def test_topics_with_no_team_counts(self): def test_topics_with_no_team_counts(self):
""" """
Verify that we serialize topics with no team count, making only one SQL Verify that we serialize topics with no team count, making only one SQL
...@@ -181,3 +196,28 @@ class PaginatedTopicSerializerTestCase(TopicTestCase): ...@@ -181,3 +196,28 @@ class PaginatedTopicSerializerTestCase(TopicTestCase):
) )
CourseTeamFactory.create(course_id=second_course.id, topic_id=duplicate_topic[u'id']) CourseTeamFactory.create(course_id=second_course.id, topic_id=duplicate_topic[u'id'])
self.assert_serializer_output(first_course_topics, num_teams_per_topic=teams_per_topic, num_queries=1) self.assert_serializer_output(first_course_topics, num_teams_per_topic=teams_per_topic, num_queries=1)
class PaginatedTopicSerializerTestCase(BasePaginatedTopicSerializerTestCase):
"""
Tests for the `PaginatedTopicSerializer`, which will add team_count information per topic if not present.
"""
__test__ = True
serializer = PaginatedTopicSerializer
def test_topics_with_team_counts(self):
"""
Verify that we serialize topics with team_count, making one SQL query per topic.
"""
teams_per_topic = 2
topics = self.setup_topics(teams_per_topic=teams_per_topic)
self.assert_serializer_output(topics, num_teams_per_topic=teams_per_topic, num_queries=5)
def test_topics_with_team_counts_prepopulated(self):
"""
Verify that if team_count is pre-populated, there are no additional SQL queries.
"""
teams_per_topic = 8
topics = self.setup_topics(teams_per_topic=teams_per_topic)
add_team_count(topics, self.course.id)
self.assert_serializer_output(topics, num_teams_per_topic=teams_per_topic, num_queries=0)
...@@ -420,7 +420,9 @@ class TestListTeamsAPI(TeamAPITestCase): ...@@ -420,7 +420,9 @@ class TestListTeamsAPI(TeamAPITestCase):
@ddt.data( @ddt.data(
(None, 200, ['Nuclear Team', u'sólar team', 'Wind Team']), (None, 200, ['Nuclear Team', u'sólar team', 'Wind Team']),
('name', 200, ['Nuclear Team', u'sólar team', 'Wind Team']), ('name', 200, ['Nuclear Team', u'sólar team', 'Wind Team']),
('open_slots', 200, ['Wind Team', u'sólar team', 'Nuclear Team']), # Note that "Nuclear Team" and "solar team" have the same number of open slots.
# "Nuclear Team" comes first due to secondary sort by name.
('open_slots', 200, ['Wind Team', 'Nuclear Team', u'sólar team']),
('last_activity', 400, []), ('last_activity', 400, []),
) )
@ddt.unpack @ddt.unpack
...@@ -700,10 +702,20 @@ class TestListTopicsAPI(TeamAPITestCase): ...@@ -700,10 +702,20 @@ class TestListTopicsAPI(TeamAPITestCase):
@ddt.data( @ddt.data(
(None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'), (None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'),
('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'), ('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'),
# Note that "Nuclear Power" and "solar power" both have 2 teams. "Coal Power" and "Window Power"
# both have 0 teams. The secondary sort is alphabetical by name.
('team_count', 200, ['Nuclear Power', u'sólar power', 'Coal Power', 'Wind Power'], 'team_count'),
('no_such_field', 400, [], None), ('no_such_field', 400, [], None),
) )
@ddt.unpack @ddt.unpack
def test_order_by(self, field, status, names, expected_ordering): def test_order_by(self, field, status, names, expected_ordering):
# Add 2 teams to "Nuclear Power", which previously had no teams.
CourseTeamFactory.create(
name=u'Nuclear Team 1', course_id=self.test_course_1.id, topic_id='topic_2'
)
CourseTeamFactory.create(
name=u'Nuclear Team 2', course_id=self.test_course_1.id, topic_id='topic_2'
)
data = {'course_id': self.test_course_1.id} data = {'course_id': self.test_course_1.id}
if field: if field:
data['order_by'] = field data['order_by'] = field
...@@ -712,6 +724,35 @@ class TestListTopicsAPI(TeamAPITestCase): ...@@ -712,6 +724,35 @@ class TestListTopicsAPI(TeamAPITestCase):
self.assertEqual(names, [topic['name'] for topic in topics['results']]) self.assertEqual(names, [topic['name'] for topic in topics['results']])
self.assertEqual(topics['sort_order'], expected_ordering) self.assertEqual(topics['sort_order'], expected_ordering)
def test_order_by_team_count_secondary(self):
"""
Ensure that the secondary sort (alphabetical) when primary sort is team_count
works across pagination boundaries.
"""
# Add 2 teams to "Wind Power", which previously had no teams.
CourseTeamFactory.create(
name=u'Wind Team 1', course_id=self.test_course_1.id, topic_id='topic_1'
)
CourseTeamFactory.create(
name=u'Wind Team 2', course_id=self.test_course_1.id, topic_id='topic_1'
)
topics = self.get_topics_list(data={
'course_id': self.test_course_1.id,
'page_size': 2,
'page': 1,
'order_by': 'team_count'
})
self.assertEqual(["Wind Power", u'sólar power'], [topic['name'] for topic in topics['results']])
topics = self.get_topics_list(data={
'course_id': self.test_course_1.id,
'page_size': 2,
'page': 2,
'order_by': 'team_count'
})
self.assertEqual(["Coal Power", "Nuclear Power"], [topic['name'] for topic in topics['results']])
def test_pagination(self): def test_pagination(self):
response = self.get_topics_list(data={ response = self.get_topics_list(data={
'course_id': self.test_course_1.id, 'course_id': self.test_course_1.id,
......
...@@ -43,11 +43,12 @@ from .models import CourseTeam, CourseTeamMembership ...@@ -43,11 +43,12 @@ from .models import CourseTeam, CourseTeamMembership
from .serializers import ( from .serializers import (
CourseTeamSerializer, CourseTeamSerializer,
CourseTeamCreationSerializer, CourseTeamCreationSerializer,
BaseTopicSerializer,
TopicSerializer, TopicSerializer,
PaginatedTopicSerializer, PaginatedTopicSerializer,
BulkTeamCountPaginatedTopicSerializer,
MembershipSerializer, MembershipSerializer,
PaginatedMembershipSerializer, PaginatedMembershipSerializer,
add_team_count
) )
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
...@@ -77,10 +78,14 @@ class TeamsDashboardView(View): ...@@ -77,10 +78,14 @@ class TeamsDashboardView(View):
not has_access(request.user, 'staff', course, course.id): not has_access(request.user, 'staff', course, course.id):
raise Http404 raise Http404
# 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' sort_order = 'name'
topics = get_ordered_topics(course, sort_order) topics = get_alphabetical_topics(course)
topics_page = Paginator(topics, TOPICS_PER_PAGE).page(1) topics_page = Paginator(topics, TOPICS_PER_PAGE).page(1)
topics_serializer = PaginatedTopicSerializer( # BulkTeamCountPaginatedTopicSerializer will add team counts to the topics in a single
# bulk operation per page.
topics_serializer = BulkTeamCountPaginatedTopicSerializer(
instance=topics_page, instance=topics_page,
context={'course_id': course.id, 'sort_order': sort_order} context={'course_id': course.id, 'sort_order': sort_order}
) )
...@@ -166,7 +171,7 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -166,7 +171,7 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
* name: Orders results by case insensitive team name (default). * name: Orders results by case insensitive team name (default).
* open_slots: Orders results by most open slots. * open_slots: Orders results by most open slots (with name as a secondary sort).
* last_activity: Currently not supported. * last_activity: Currently not supported.
...@@ -322,14 +327,15 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -322,14 +327,15 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
) )
queryset = CourseTeam.objects.filter(**result_filter) queryset = CourseTeam.objects.filter(**result_filter)
# We will always use name as either a primary or secondary sort.
queryset = queryset.extra(select={'lower_name': "lower(name)"})
order_by_input = request.QUERY_PARAMS.get('order_by', 'name') order_by_input = request.QUERY_PARAMS.get('order_by', 'name')
if order_by_input == 'name': if order_by_input == 'name':
queryset = queryset.extra(select={'lower_name': "lower(name)"}) queryset = queryset.order_by('lower_name')
order_by_field = 'lower_name'
elif order_by_input == 'open_slots': elif order_by_input == 'open_slots':
queryset = queryset.annotate(team_size=Count('users')) queryset = queryset.annotate(team_size=Count('users'))
order_by_field = 'team_size' queryset = queryset.order_by('team_size', 'lower_name')
elif order_by_input == 'last_activity': elif order_by_input == 'last_activity':
return Response( return Response(
build_api_error(ugettext_noop("last_activity is not yet supported")), build_api_error(ugettext_noop("last_activity is not yet supported")),
...@@ -345,8 +351,6 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -345,8 +351,6 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
'user_message': _(u"The ordering {ordering} is not supported").format(ordering=order_by_input), 'user_message': _(u"The ordering {ordering} is not supported").format(ordering=order_by_input),
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.order_by(order_by_field)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
serializer = self.get_pagination_serializer(page) serializer = self.get_pagination_serializer(page)
serializer.context.update({'sort_order': order_by_input}) # pylint: disable=maybe-no-member serializer.context.update({'sort_order': order_by_input}) # pylint: disable=maybe-no-member
...@@ -531,8 +535,9 @@ class TopicListView(GenericAPIView): ...@@ -531,8 +535,9 @@ class TopicListView(GenericAPIView):
* course_id: Filters the result to topics belonging to the given * course_id: Filters the result to topics belonging to the given
course (required). course (required).
* order_by: Orders the results. Currently only 'name' is supported, * order_by: Orders the results. Currently only 'name' and 'team_count' are supported;
and is also the default value. 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_size: Number of results to return per page.
...@@ -578,8 +583,6 @@ class TopicListView(GenericAPIView): ...@@ -578,8 +583,6 @@ class TopicListView(GenericAPIView):
paginate_by = TOPICS_PER_PAGE paginate_by = TOPICS_PER_PAGE
paginate_by_param = 'page_size' paginate_by_param = 'page_size'
pagination_serializer_class = PaginatedTopicSerializer
serializer_class = BaseTopicSerializer
def get(self, request): def get(self, request):
"""GET /api/team/v0/topics/?course_id={course_id}""" """GET /api/team/v0/topics/?course_id={course_id}"""
...@@ -608,9 +611,7 @@ class TopicListView(GenericAPIView): ...@@ -608,9 +611,7 @@ class TopicListView(GenericAPIView):
return Response(status=status.HTTP_403_FORBIDDEN) return Response(status=status.HTTP_403_FORBIDDEN)
ordering = request.QUERY_PARAMS.get('order_by', 'name') ordering = request.QUERY_PARAMS.get('order_by', 'name')
if ordering == 'name': if ordering not in ['name', 'team_count']:
topics = get_ordered_topics(course_module, ordering)
else:
return Response({ return Response({
'developer_message': "unsupported order_by value {ordering}".format(ordering=ordering), 'developer_message': "unsupported order_by value {ordering}".format(ordering=ordering),
# Translators: 'ordering' is a string describing a way # Translators: 'ordering' is a string describing a way
...@@ -620,22 +621,37 @@ class TopicListView(GenericAPIView): ...@@ -620,22 +621,37 @@ class TopicListView(GenericAPIView):
'user_message': _(u"The ordering {ordering} is not supported").format(ordering=ordering), 'user_message': _(u"The ordering {ordering} is not supported").format(ordering=ordering),
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
page = self.paginate_queryset(topics) # Always sort alphabetically, as it will be used as secondary sort
serializer = self.pagination_serializer_class(page, context={'course_id': course_id, 'sort_order': ordering}) # 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)
# Since team_count has already been added to all the topics, use PaginatedTopicSerializer.
# 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.
serializer = PaginatedTopicSerializer(page, context={'course_id': course_id, 'sort_order': ordering})
else:
page = self.paginate_queryset(topics)
# Use the serializer that adds team_count in a bulk operation per page.
serializer = BulkTeamCountPaginatedTopicSerializer(
page, context={'course_id': course_id, 'sort_order': ordering}
)
return Response(serializer.data) return Response(serializer.data)
def get_ordered_topics(course_module, ordering): def get_alphabetical_topics(course_module):
"""Return a sorted list of team topics. """Return a list of team topics sorted alphabetically.
Arguments: Arguments:
course_module (xmodule): the course which owns the team topics course_module (xmodule): the course which owns the team topics
ordering (str): the key belonging to topic dicts by which we sort
Returns: Returns:
list: a list of sorted team topics list: a list of sorted team topics
""" """
return sorted(course_module.teams_topics, key=lambda t: t[ordering].lower()) return sorted(course_module.teams_topics, key=lambda t: t['name'].lower())
class TopicDetailView(APIView): class TopicDetailView(APIView):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment