Commit 276cba90 by Christina Roberts

Merge pull request #9262 from edx/christina/search-sort

Add sorting by team_count, as well as secondary sort (by name).
parents a361191e 9fe6dd27
...@@ -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"""
# 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() 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 def add_team_count(topics, course_id):
# and outputs the result as a list of dicts (one per topic). """
topic_ids = [topic['id'] for topic in self.data['results']] Helper method to add team_count for a list of topics.
This allows for a more efficient single query.
"""
topic_ids = [topic['id'] for topic in topics]
teams_per_topic = CourseTeam.objects.filter( teams_per_topic = CourseTeam.objects.filter(
course_id=self.context['course_id'], course_id=course_id,
topic_id__in=topic_ids topic_id__in=topic_ids
).values('topic_id').annotate(team_count=Count('topic_id')) ).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} topics_to_team_count = {d['topic_id']: d['team_count'] for d in teams_per_topic}
for topic in self.data['results']: for topic in topics:
topic['team_count'] = topics_to_team_count.get(topic['id'], 0) 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)
...@@ -436,7 +436,9 @@ class TestListTeamsAPI(TeamAPITestCase): ...@@ -436,7 +436,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
...@@ -740,10 +742,20 @@ class TestListTopicsAPI(TeamAPITestCase): ...@@ -740,10 +742,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
...@@ -752,6 +764,35 @@ class TestListTopicsAPI(TeamAPITestCase): ...@@ -752,6 +764,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}
) )
...@@ -170,7 +175,7 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -170,7 +175,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.
...@@ -326,14 +331,15 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -326,14 +331,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")),
...@@ -349,8 +355,6 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -349,8 +355,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
...@@ -408,6 +412,28 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -408,6 +412,28 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
return Response(CourseTeamSerializer(team).data) return Response(CourseTeamSerializer(team).data)
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): class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
""" """
**Use Cases** **Use Cases**
...@@ -490,26 +516,6 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView): ...@@ -490,26 +516,6 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
method returns a 400 error with all error messages in the method returns a 400 error with all error messages in the
"field_errors" field of the returned JSON. "field_errors" field of the returned JSON.
""" """
class IsEnrolledOrIsStaff(permissions.BasePermission):
"""Permission that checks to see if the user is enrolled in the course or is staff."""
def has_object_permission(self, request, view, obj):
"""Returns true if the user is enrolled or is staff."""
return has_team_api_access(request.user, obj.course_id)
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
IsStaffOrReadOnly.has_object_permission(self, request, view, obj)
)
authentication_classes = (OAuth2Authentication, SessionAuthentication) authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, IsEnrolledOrIsStaff,) permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, IsEnrolledOrIsStaff,)
lookup_field = 'team_id' lookup_field = 'team_id'
...@@ -536,8 +542,9 @@ class TopicListView(GenericAPIView): ...@@ -536,8 +542,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.
...@@ -583,8 +590,6 @@ class TopicListView(GenericAPIView): ...@@ -583,8 +590,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}"""
...@@ -613,9 +618,7 @@ class TopicListView(GenericAPIView): ...@@ -613,9 +618,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
...@@ -625,22 +628,37 @@ class TopicListView(GenericAPIView): ...@@ -625,22 +628,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)
# 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)
# 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) page = self.paginate_queryset(topics)
serializer = self.pagination_serializer_class(page, context={'course_id': course_id, 'sort_order': ordering}) # 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