Commit e4f67070 by Clinton Blackburn Committed by Clinton Blackburn

Added availability facets

ECOM-4855
parent f69aa38f
from django.contrib.auth import get_user_model
from django.utils.translation import ugettext as _
from drf_haystack.filters import HaystackFacetFilter
from drf_haystack.query import FacetQueryBuilder
from dry_rest_permissions.generics import DRYPermissionFiltersBase
from guardian.shortcuts import get_objects_for_user
from rest_framework.exceptions import PermissionDenied, NotFound
......@@ -38,3 +40,15 @@ class PermissionsFilter(DRYPermissionFiltersBase):
)
return get_objects_for_user(user, perm)
class FacetQueryBuilderWithQueries(FacetQueryBuilder):
def build_query(self, **filters):
query = super(FacetQueryBuilderWithQueries, self).build_query(**filters)
facet_serializer_cls = self.view.get_facet_serializer_class()
query['query_facets'] = facet_serializer_cls.Meta.field_queries
return query
class HaystackFacetFilterWithQueries(HaystackFacetFilter):
query_builder_class = FacetQueryBuilderWithQueries
......@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _
from drf_haystack.serializers import HaystackSerializer, HaystackFacetSerializer
from rest_framework import serializers
from rest_framework.fields import DictField
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.course_metadata.models import (
......@@ -30,6 +31,13 @@ COURSE_RUN_FACET_FIELD_OPTIONS = {
'content_type': {},
'type': {},
}
COURSE_RUN_FACET_FIELD_QUERIES = {
'availability_current': {'query': 'start:<now AND end:>now'},
'availability_starting_soon': {'query': 'start:[now TO now+60d]'},
'availability_upcoming': {'query': 'start:[now+60d TO *]'},
'availability_archived': {'query': 'end:<=now'},
}
COURSE_RUN_SEARCH_FIELDS = (
'key', 'title', 'short_description', 'full_description', 'start', 'end', 'enrollment_start', 'enrollment_end',
'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'org', 'number', 'seat_types',
......@@ -359,6 +367,82 @@ class FlattenedCourseRunWithCourseSerializer(CourseRunSerializer):
return obj.course.key
class QueryFacetFieldSerializer(serializers.Serializer):
count = serializers.IntegerField()
narrow_url = serializers.SerializerMethodField()
def get_paginate_by_param(self):
"""
Returns the ``paginate_by_param`` for the (root) view paginator class.
This is needed in order to remove the query parameter from faceted
narrow urls.
If using a custom pagination class, this class attribute needs to
be set manually.
"""
# NOTE (CCB): We use PageNumberPagination. See drf-haystack's FacetFieldSerializer.get_paginate_by_param
# for complete code that is applicable to any pagination class.
pagination_class = self.context['view'].pagination_class
return pagination_class.page_query_param
def get_narrow_url(self, instance):
"""
Return a link suitable for narrowing on the current item.
Since we don't have any means of getting the ``view name`` from here,
we can only return relative paths.
"""
field = instance['field']
request = self.context['request']
query_params = request.GET.copy()
# Never keep the page query parameter in narrowing urls.
# It will raise a NotFound exception when trying to paginate a narrowed queryset.
page_query_param = self.get_paginate_by_param()
if page_query_param in query_params:
del query_params[page_query_param]
selected_facets = set(query_params.pop('selected_query_facets', []))
selected_facets.add(field)
query_params.setlist('selected_query_facets', sorted(selected_facets))
path = '{path}?{query}'.format(path=request.path_info, query=query_params.urlencode())
url = request.build_absolute_uri(path)
return serializers.Hyperlink(url, name='narrow-url')
class BaseHaystackFacetSerializer(HaystackFacetSerializer):
_abstract = True
def get_fields(self):
query_facet_counts = self.instance.pop('queries')
field_mapping = super(BaseHaystackFacetSerializer, self).get_fields()
query_data = self.format_query_facet_data(query_facet_counts)
field_mapping['queries'] = DictField(query_data, child=QueryFacetFieldSerializer(), required=False)
if self.serialize_objects:
field_mapping.move_to_end('objects')
self.instance['queries'] = query_data
return field_mapping
def format_query_facet_data(self, query_facet_counts):
query_data = {}
for field, options in self.Meta.field_queries.items(): # pylint: disable=no-member
count = query_facet_counts.get(field, 0)
if count:
query_data[field] = {
'field': field,
'options': options,
'count': count,
}
return query_data
class CourseSearchSerializer(HaystackSerializer):
content_type = serializers.CharField(source='model_name')
......@@ -369,7 +453,7 @@ class CourseSearchSerializer(HaystackSerializer):
index_classes = [CourseIndex]
class CourseFacetSerializer(HaystackFacetSerializer):
class CourseFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
......@@ -393,12 +477,13 @@ class CourseRunSearchSerializer(HaystackSerializer):
index_classes = [CourseRunIndex]
class CourseRunFacetSerializer(HaystackFacetSerializer):
class CourseRunFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
field_options = COURSE_RUN_FACET_FIELD_OPTIONS
field_queries = COURSE_RUN_FACET_FIELD_QUERIES
ignore_fields = COMMON_IGNORED_FIELDS
......@@ -413,12 +498,13 @@ class AggregateSearchSerializer(HaystackSerializer):
}
class AggregateFacetSearchSerializer(HaystackFacetSerializer):
class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
field_options = COURSE_RUN_FACET_FIELD_OPTIONS
field_queries = COURSE_RUN_FACET_FIELD_QUERIES
ignore_fields = COMMON_IGNORED_FIELDS
serializers = {
CourseRunIndex: CourseRunFacetSerializer,
......
import datetime
import json
import urllib.parse
......@@ -81,3 +82,69 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
self.assertDictContainsSubset(expected, actual)
return course_run, response_data
def build_facet_url(self, params):
return 'http://testserver{path}?{query}'.format(path=self.faceted_path, query=urllib.parse.urlencode(params))
def test_invalid_query_facet(self):
""" Verify the endpoint returns HTTP 400 if an invalid facet is requested. """
facet = 'not-a-facet'
url = '{path}?selected_query_facets={facet}'.format(path=self.faceted_path, facet=facet)
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content.decode('utf-8'))
expected = {'detail': 'The selected query facet [{facet}] is not valid.'.format(facet=facet)}
self.assertEqual(response_data, expected)
def test_availability_faceting(self):
""" Verify the endpoint returns availability facets with the results. """
now = datetime.datetime.utcnow()
archived = CourseRunFactory(start=now - datetime.timedelta(weeks=2), end=now - datetime.timedelta(weeks=1))
current = CourseRunFactory(start=now - datetime.timedelta(weeks=2), end=now + datetime.timedelta(weeks=1))
starting_soon = CourseRunFactory(start=now + datetime.timedelta(days=10), end=now + datetime.timedelta(days=90))
upcoming = CourseRunFactory(start=now + datetime.timedelta(days=61), end=now + datetime.timedelta(days=90))
response = self.get_search_response(faceted=True)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
# Verify all course runs are returned
self.assertEqual(response_data['objects']['count'], 4)
expected = [self.serialize_course_run(course_run) for course_run in
[archived, current, starting_soon, upcoming]]
self.assertEqual(response_data['objects']['results'], expected)
self.assert_response_includes_availability_facets(response_data)
# Verify the results can be filtered based on availability
url = '{path}?page=1&selected_query_facets={facet}'.format(
path=self.faceted_path, facet='availability_archived'
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(response_data['objects']['results'], [self.serialize_course_run(archived)])
def assert_response_includes_availability_facets(self, response_data):
""" Verifies the query facet counts/URLs are properly rendered. """
expected = {
'availability_archived': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_archived'})
},
'availability_current': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_current'})
},
'availability_starting_soon': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_starting_soon'})
},
'availability_upcoming': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_upcoming'})
},
}
self.assertDictContainsSubset(expected, response_data['queries'])
......@@ -11,19 +11,19 @@ from django.db.models import Q
from django.db.models.functions import Lower
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from drf_haystack.filters import HaystackFacetFilter, HaystackFilter
from drf_haystack.filters import HaystackFilter
from drf_haystack.mixins import FacetMixin
from drf_haystack.viewsets import HaystackViewSet
from dry_rest_permissions.generics import DRYPermissions
from edx_rest_framework_extensions.permissions import IsSuperuser
from rest_framework import status, viewsets
from rest_framework.decorators import detail_route, list_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import PermissionDenied, ParseError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from course_discovery.apps.api import serializers
from course_discovery.apps.api.filters import PermissionsFilter
from course_discovery.apps.api.filters import PermissionsFilter, HaystackFacetFilterWithQueries
from course_discovery.apps.api.pagination import PageNumberPagination
from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer, CourseRunCSVRenderer
from course_discovery.apps.catalogs.models import Catalog
......@@ -364,7 +364,7 @@ class AffiliateWindowViewSet(viewsets.ViewSet):
class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
document_uid_field = 'key'
facet_filter_backends = [HaystackFacetFilter, HaystackFilter]
facet_filter_backends = [HaystackFacetFilterWithQueries, HaystackFilter]
load_all = True
lookup_field = 'key'
permission_classes = (IsAuthenticated,)
......@@ -397,9 +397,41 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
paramType: query
type: string
required: false
- name: selected_facets
description: Field facets
paramType: query
allowMultiple: true
type: array
items:
pytype: str
required: false
- name: selected_query_facets
description: Query facets
paramType: query
allowMultiple: true
type: array
items:
pytype: str
required: false
"""
return super(BaseCourseHaystackViewSet, self).facets(request)
def filter_facet_queryset(self, queryset):
queryset = super(BaseCourseHaystackViewSet, self).filter_facet_queryset(queryset)
facet_serializer_cls = self.get_facet_serializer_class()
field_queries = facet_serializer_cls.Meta.field_queries
for facet in self.request.query_params.getlist('selected_query_facets'):
query = field_queries.get(facet)
if not query:
raise ParseError('The selected query facet [{facet}] is not valid.'.format(facet=facet))
queryset = queryset.raw_search(query['query'])
return queryset
class CourseSearchViewSet(BaseCourseHaystackViewSet):
facet_serializer_class = serializers.CourseFacetSerializer
......
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