Commit d769550d by Mushtaq Ali Committed by GitHub

Merge pull request #50 from edx/mushtaq/edxnotes-multiple-usageids

Support bulk search in /search endpoint
parents eae09382 3d9affca
...@@ -2,3 +2,4 @@ Oleg Marshev <oleg@edx.org> ...@@ -2,3 +2,4 @@ Oleg Marshev <oleg@edx.org>
Tim Babych <tim.babych@gmail.com> Tim Babych <tim.babych@gmail.com>
Christina Roberts <christina@edx.org> Christina Roberts <christina@edx.org>
Ben McMorran <ben.mcmorran@gmail.com> Ben McMorran <ben.mcmorran@gmail.com>
Mushtaq Ali <mushtaak@gmail.com>
...@@ -680,6 +680,32 @@ class AnnotationSearchViewTests(BaseAnnotationViewTests): ...@@ -680,6 +680,32 @@ class AnnotationSearchViewTests(BaseAnnotationViewTests):
search_and_verify("First", "First one", []) search_and_verify("First", "First one", [])
search_and_verify("Second", "Second note", ["tag1", "tag2"]) search_and_verify("Second", "Second note", ["tag1", "tag2"])
@ddt.data(True, False)
def test_usage_id_search(self, is_es_disabled):
"""
Verifies the search with usage id.
"""
self._create_annotation(text=u'First one', usage_id='test-1')
self._create_annotation(text=u'Second note', usage_id='test-2')
self._create_annotation(text=u'Third note', usage_id='test-3')
@patch('django.conf.settings.ES_DISABLED', is_es_disabled)
def verify_usage_id_search(usage_ids):
"""
Verify search results based on usage id operation.
Arguments:
usage_ids: List. The identifier string of the annotations XBlock(s).
"""
results = self._get_search_results(usage_id=usage_ids)
self.assertEqual(len(results), len(usage_ids))
# Here we are reverse-traversing usage_ids because response has descending ordered rows.
for index, u_id in enumerate(usage_ids[::-1]):
self.assertEqual(results[index]['usage_id'], u_id)
verify_usage_id_search(usage_ids=['test-1'])
verify_usage_id_search(usage_ids=['test-1', 'test-2', 'test-3'])
def test_search_deleted(self): def test_search_deleted(self):
""" """
Tests for search method to not return deleted notes. Tests for search method to not return deleted notes.
......
...@@ -35,10 +35,12 @@ class AnnotationSearchView(GenericAPIView): ...@@ -35,10 +35,12 @@ class AnnotationSearchView(GenericAPIView):
""" """
**Use Case** **Use Case**
* Search and return a paginated list of annotations for a user. * Search and return a list of annotations for a user.
The annotations are always sorted in descending order by updated date. The annotations are always sorted in descending order by updated date.
Response is paginated by default except usage_id based search.
Each page in the list contains 25 annotations by default. The page Each page in the list contains 25 annotations by default. The page
size can be altered by passing parameter "page_size=<page_size>". size can be altered by passing parameter "page_size=<page_size>".
...@@ -58,6 +60,8 @@ class AnnotationSearchView(GenericAPIView): ...@@ -58,6 +60,8 @@ class AnnotationSearchView(GenericAPIView):
GET /api/v1/search/ GET /api/v1/search/
GET /api/v1/search/?course_id={course_id}&user={user_id} GET /api/v1/search/?course_id={course_id}&user={user_id}
GET /api/v1/search/?course_id={course_id}&user={user_id}&usage_id={usage_id}
GET /api/v1/search/?course_id={course_id}&user={user_id}&usage_id={usage_id}&usage_id={usage_id} ...
**Query Parameters for GET** **Query Parameters for GET**
...@@ -67,6 +71,8 @@ class AnnotationSearchView(GenericAPIView): ...@@ -67,6 +71,8 @@ class AnnotationSearchView(GenericAPIView):
* user: Anonymized user id. * user: Anonymized user id.
* usage_id: The identifier string of the annotations XBlock.
* text: Student's thoughts on the quote * text: Student's thoughts on the quote
* highlight: dict. Only used when search from ElasticSearch. It contains two keys: * highlight: dict. Only used when search from ElasticSearch. It contains two keys:
...@@ -109,31 +115,49 @@ class AnnotationSearchView(GenericAPIView): ...@@ -109,31 +115,49 @@ class AnnotationSearchView(GenericAPIView):
* updated: DateTime. When was the last time annotation was updated. * updated: DateTime. When was the last time annotation was updated.
""" """
params = {}
query_params = {}
search_with_usage_id = False
def get(self, *args, **kwargs): # pylint: disable=unused-argument def get(self, *args, **kwargs): # pylint: disable=unused-argument
""" """
Search annotations in most appropriate storage Search annotations in most appropriate storage
""" """
self.query_params = {}
self.search_with_usage_id = False
self.params = self.request.query_params.dict()
usage_ids = self.request.query_params.getlist('usage_id')
if len(usage_ids) > 0:
self.search_with_usage_id = True
self.query_params['usage_id__in'] = usage_ids
if 'course_id' in self.params:
self.query_params['course_id'] = self.params['course_id']
# search in DB when ES is not available or there is no need to bother it # search in DB when ES is not available or there is no need to bother it
if settings.ES_DISABLED or 'text' not in self.request.query_params.dict(): if settings.ES_DISABLED or 'text' not in self.params:
if 'user' in self.params:
self.query_params['user_id'] = self.params['user']
return self.get_from_db(*args, **kwargs) return self.get_from_db(*args, **kwargs)
else: else:
if 'user' in self.params:
self.query_params['user'] = self.params['user']
return self.get_from_es(*args, **kwargs) return self.get_from_es(*args, **kwargs)
def get_from_db(self, *args, **kwargs): # pylint: disable=unused-argument def get_from_db(self, *args, **kwargs): # pylint: disable=unused-argument
""" """
Search annotations in database. Search annotations in database.
""" """
params = self.request.query_params.dict() query = Note.objects.filter(**self.query_params).order_by('-updated')
query = Note.objects.filter(
**{f: v for (f, v) in params.items() if f in ('course_id', 'usage_id')}
).order_by('-updated')
if 'user' in params: if 'text' in self.params:
query = query.filter(user_id=params['user']) query = query.filter(Q(text__icontains=self.params['text']) | Q(tags__icontains=self.params['text']))
if 'text' in params: # Do not send paginated result if usage id based search.
query = query.filter(Q(text__icontains=params['text']) | Q(tags__icontains=params['text'])) if self.search_with_usage_id:
serializer = NoteSerializer(query, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
page = self.paginate_queryset(query) page = self.paginate_queryset(query)
serializer = NoteSerializer(page, many=True) serializer = NoteSerializer(page, many=True)
...@@ -144,16 +168,13 @@ class AnnotationSearchView(GenericAPIView): ...@@ -144,16 +168,13 @@ class AnnotationSearchView(GenericAPIView):
""" """
Search annotations in ElasticSearch. Search annotations in ElasticSearch.
""" """
params = self.request.query_params.dict() query = SearchQuerySet().models(Note).filter(**self.query_params)
query = SearchQuerySet().models(Note).filter(
**{f: v for (f, v) in params.items() if f in ('user', 'course_id', 'usage_id')}
)
if 'text' in params: if 'text' in self.params:
clean_text = query.query.clean(params['text']) clean_text = query.query.clean(self.params['text'])
query = query.filter(SQ(data=clean_text)) query = query.filter(SQ(data=clean_text))
if params.get('highlight'): if self.params.get('highlight'):
opts = { opts = {
'pre_tags': ['{elasticsearch_highlight_start}'], 'pre_tags': ['{elasticsearch_highlight_start}'],
'post_tags': ['{elasticsearch_highlight_end}'], 'post_tags': ['{elasticsearch_highlight_end}'],
...@@ -161,6 +182,11 @@ class AnnotationSearchView(GenericAPIView): ...@@ -161,6 +182,11 @@ class AnnotationSearchView(GenericAPIView):
} }
query = query.highlight(**opts) query = query.highlight(**opts)
# Do not send paginated result if usage id based search.
if self.search_with_usage_id:
serializer = NotesElasticSearchSerializer(query, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
page = self.paginate_queryset(query) page = self.paginate_queryset(query)
serializer = NotesElasticSearchSerializer(page, many=True) serializer = NotesElasticSearchSerializer(page, many=True)
response = self.get_paginated_response(serializer.data) response = self.get_paginated_response(serializer.data)
......
import json
import datetime import datetime
from unittest import skipIf from unittest import skipIf
from mock import patch, Mock from mock import patch, Mock
...@@ -17,7 +18,7 @@ class OperationalEndpointsTest(APITestCase): ...@@ -17,7 +18,7 @@ class OperationalEndpointsTest(APITestCase):
""" """
response = self.client.get(reverse('heartbeat')) response = self.client.get(reverse('heartbeat'))
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertEquals(response.data, {"OK": True}) self.assertEquals(json.loads(response.content), {"OK": True})
@skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.") @skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.")
@patch('notesserver.views.get_es') @patch('notesserver.views.get_es')
...@@ -28,7 +29,7 @@ class OperationalEndpointsTest(APITestCase): ...@@ -28,7 +29,7 @@ class OperationalEndpointsTest(APITestCase):
mocked_get_es.return_value.ping.return_value = False mocked_get_es.return_value.ping.return_value = False
response = self.client.get(reverse('heartbeat')) response = self.client.get(reverse('heartbeat'))
self.assertEquals(response.status_code, 500) self.assertEquals(response.status_code, 500)
self.assertEquals(response.data, {"OK": False, "check": "es"}) self.assertEquals(json.loads(response.content), {"OK": False, "check": "es"})
@patch("django.db.backends.utils.CursorWrapper") @patch("django.db.backends.utils.CursorWrapper")
def test_heartbeat_failure_db(self, mocked_cursor_wrapper): def test_heartbeat_failure_db(self, mocked_cursor_wrapper):
...@@ -38,7 +39,7 @@ class OperationalEndpointsTest(APITestCase): ...@@ -38,7 +39,7 @@ class OperationalEndpointsTest(APITestCase):
mocked_cursor_wrapper.side_effect = Exception mocked_cursor_wrapper.side_effect = Exception
response = self.client.get(reverse('heartbeat')) response = self.client.get(reverse('heartbeat'))
self.assertEquals(response.status_code, 500) self.assertEquals(response.status_code, 500)
self.assertEquals(response.data, {"OK": False, "check": "db"}) self.assertEquals(json.loads(response.content), {"OK": False, "check": "db"})
def test_root(self): def test_root(self):
""" """
......
...@@ -3,6 +3,8 @@ import datetime ...@@ -3,6 +3,8 @@ import datetime
from django.db import connection from django.db import connection
from django.conf import settings from django.conf import settings
from django.http import JsonResponse
from rest_framework import status from rest_framework import status
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
...@@ -38,12 +40,12 @@ def heartbeat(request): # pylint: disable=unused-argument ...@@ -38,12 +40,12 @@ def heartbeat(request): # pylint: disable=unused-argument
try: try:
db_status() db_status()
except Exception: except Exception:
return Response({"OK": False, "check": "db"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return JsonResponse({"OK": False, "check": "db"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
if not settings.ES_DISABLED and not get_es().ping(): if not settings.ES_DISABLED and not get_es().ping():
return Response({"OK": False, "check": "es"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return JsonResponse({"OK": False, "check": "es"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({"OK": True}) return JsonResponse({"OK": True}, status=status.HTTP_200_OK)
@api_view(['GET']) @api_view(['GET'])
......
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