Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
C
course-discovery
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
course-discovery
Commits
e4f67070
Commit
e4f67070
authored
Jun 30, 2016
by
Clinton Blackburn
Committed by
Clinton Blackburn
Jul 01, 2016
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Added availability facets
ECOM-4855
parent
f69aa38f
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
206 additions
and
7 deletions
+206
-7
course_discovery/apps/api/filters.py
+14
-0
course_discovery/apps/api/serializers.py
+89
-3
course_discovery/apps/api/v1/tests/test_views/test_search.py
+67
-0
course_discovery/apps/api/v1/views.py
+36
-4
No files found.
course_discovery/apps/api/filters.py
View file @
e4f67070
from
django.contrib.auth
import
get_user_model
from
django.contrib.auth
import
get_user_model
from
django.utils.translation
import
ugettext
as
_
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
dry_rest_permissions.generics
import
DRYPermissionFiltersBase
from
guardian.shortcuts
import
get_objects_for_user
from
guardian.shortcuts
import
get_objects_for_user
from
rest_framework.exceptions
import
PermissionDenied
,
NotFound
from
rest_framework.exceptions
import
PermissionDenied
,
NotFound
...
@@ -38,3 +40,15 @@ class PermissionsFilter(DRYPermissionFiltersBase):
...
@@ -38,3 +40,15 @@ class PermissionsFilter(DRYPermissionFiltersBase):
)
)
return
get_objects_for_user
(
user
,
perm
)
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
course_discovery/apps/api/serializers.py
View file @
e4f67070
...
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
...
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from
django.utils.translation
import
ugettext_lazy
as
_
from
django.utils.translation
import
ugettext_lazy
as
_
from
drf_haystack.serializers
import
HaystackSerializer
,
HaystackFacetSerializer
from
drf_haystack.serializers
import
HaystackSerializer
,
HaystackFacetSerializer
from
rest_framework
import
serializers
from
rest_framework
import
serializers
from
rest_framework.fields
import
DictField
from
course_discovery.apps.catalogs.models
import
Catalog
from
course_discovery.apps.catalogs.models
import
Catalog
from
course_discovery.apps.course_metadata.models
import
(
from
course_discovery.apps.course_metadata.models
import
(
...
@@ -30,6 +31,13 @@ COURSE_RUN_FACET_FIELD_OPTIONS = {
...
@@ -30,6 +31,13 @@ COURSE_RUN_FACET_FIELD_OPTIONS = {
'content_type'
:
{},
'content_type'
:
{},
'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
=
(
COURSE_RUN_SEARCH_FIELDS
=
(
'key'
,
'title'
,
'short_description'
,
'full_description'
,
'start'
,
'end'
,
'enrollment_start'
,
'enrollment_end'
,
'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'
,
'pacing_type'
,
'language'
,
'transcript_languages'
,
'marketing_url'
,
'content_type'
,
'org'
,
'number'
,
'seat_types'
,
...
@@ -359,6 +367,82 @@ class FlattenedCourseRunWithCourseSerializer(CourseRunSerializer):
...
@@ -359,6 +367,82 @@ class FlattenedCourseRunWithCourseSerializer(CourseRunSerializer):
return
obj
.
course
.
key
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
):
class
CourseSearchSerializer
(
HaystackSerializer
):
content_type
=
serializers
.
CharField
(
source
=
'model_name'
)
content_type
=
serializers
.
CharField
(
source
=
'model_name'
)
...
@@ -369,7 +453,7 @@ class CourseSearchSerializer(HaystackSerializer):
...
@@ -369,7 +453,7 @@ class CourseSearchSerializer(HaystackSerializer):
index_classes
=
[
CourseIndex
]
index_classes
=
[
CourseIndex
]
class
CourseFacetSerializer
(
HaystackFacetSerializer
):
class
CourseFacetSerializer
(
Base
HaystackFacetSerializer
):
serialize_objects
=
True
serialize_objects
=
True
class
Meta
:
class
Meta
:
...
@@ -393,12 +477,13 @@ class CourseRunSearchSerializer(HaystackSerializer):
...
@@ -393,12 +477,13 @@ class CourseRunSearchSerializer(HaystackSerializer):
index_classes
=
[
CourseRunIndex
]
index_classes
=
[
CourseRunIndex
]
class
CourseRunFacetSerializer
(
HaystackFacetSerializer
):
class
CourseRunFacetSerializer
(
Base
HaystackFacetSerializer
):
serialize_objects
=
True
serialize_objects
=
True
class
Meta
:
class
Meta
:
field_aliases
=
COMMON_SEARCH_FIELD_ALIASES
field_aliases
=
COMMON_SEARCH_FIELD_ALIASES
field_options
=
COURSE_RUN_FACET_FIELD_OPTIONS
field_options
=
COURSE_RUN_FACET_FIELD_OPTIONS
field_queries
=
COURSE_RUN_FACET_FIELD_QUERIES
ignore_fields
=
COMMON_IGNORED_FIELDS
ignore_fields
=
COMMON_IGNORED_FIELDS
...
@@ -413,12 +498,13 @@ class AggregateSearchSerializer(HaystackSerializer):
...
@@ -413,12 +498,13 @@ class AggregateSearchSerializer(HaystackSerializer):
}
}
class
AggregateFacetSearchSerializer
(
HaystackFacetSerializer
):
class
AggregateFacetSearchSerializer
(
Base
HaystackFacetSerializer
):
serialize_objects
=
True
serialize_objects
=
True
class
Meta
:
class
Meta
:
field_aliases
=
COMMON_SEARCH_FIELD_ALIASES
field_aliases
=
COMMON_SEARCH_FIELD_ALIASES
field_options
=
COURSE_RUN_FACET_FIELD_OPTIONS
field_options
=
COURSE_RUN_FACET_FIELD_OPTIONS
field_queries
=
COURSE_RUN_FACET_FIELD_QUERIES
ignore_fields
=
COMMON_IGNORED_FIELDS
ignore_fields
=
COMMON_IGNORED_FIELDS
serializers
=
{
serializers
=
{
CourseRunIndex
:
CourseRunFacetSerializer
,
CourseRunIndex
:
CourseRunFacetSerializer
,
...
...
course_discovery/apps/api/v1/tests/test_views/test_search.py
View file @
e4f67070
import
datetime
import
json
import
json
import
urllib.parse
import
urllib.parse
...
@@ -81,3 +82,69 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
...
@@ -81,3 +82,69 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
self
.
assertDictContainsSubset
(
expected
,
actual
)
self
.
assertDictContainsSubset
(
expected
,
actual
)
return
course_run
,
response_data
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'
])
course_discovery/apps/api/v1/views.py
View file @
e4f67070
...
@@ -11,19 +11,19 @@ from django.db.models import Q
...
@@ -11,19 +11,19 @@ from django.db.models import Q
from
django.db.models.functions
import
Lower
from
django.db.models.functions
import
Lower
from
django.http
import
HttpResponse
from
django.http
import
HttpResponse
from
django.shortcuts
import
get_object_or_404
from
django.shortcuts
import
get_object_or_404
from
drf_haystack.filters
import
HaystackF
acetFilter
,
HaystackF
ilter
from
drf_haystack.filters
import
HaystackFilter
from
drf_haystack.mixins
import
FacetMixin
from
drf_haystack.mixins
import
FacetMixin
from
drf_haystack.viewsets
import
HaystackViewSet
from
drf_haystack.viewsets
import
HaystackViewSet
from
dry_rest_permissions.generics
import
DRYPermissions
from
dry_rest_permissions.generics
import
DRYPermissions
from
edx_rest_framework_extensions.permissions
import
IsSuperuser
from
edx_rest_framework_extensions.permissions
import
IsSuperuser
from
rest_framework
import
status
,
viewsets
from
rest_framework
import
status
,
viewsets
from
rest_framework.decorators
import
detail_route
,
list_route
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.permissions
import
IsAuthenticated
from
rest_framework.response
import
Response
from
rest_framework.response
import
Response
from
course_discovery.apps.api
import
serializers
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.pagination
import
PageNumberPagination
from
course_discovery.apps.api.renderers
import
AffiliateWindowXMLRenderer
,
CourseRunCSVRenderer
from
course_discovery.apps.api.renderers
import
AffiliateWindowXMLRenderer
,
CourseRunCSVRenderer
from
course_discovery.apps.catalogs.models
import
Catalog
from
course_discovery.apps.catalogs.models
import
Catalog
...
@@ -364,7 +364,7 @@ class AffiliateWindowViewSet(viewsets.ViewSet):
...
@@ -364,7 +364,7 @@ class AffiliateWindowViewSet(viewsets.ViewSet):
class
BaseCourseHaystackViewSet
(
FacetMixin
,
HaystackViewSet
):
class
BaseCourseHaystackViewSet
(
FacetMixin
,
HaystackViewSet
):
document_uid_field
=
'key'
document_uid_field
=
'key'
facet_filter_backends
=
[
HaystackFacetFilter
,
HaystackFilter
]
facet_filter_backends
=
[
HaystackFacetFilter
WithQueries
,
HaystackFilter
]
load_all
=
True
load_all
=
True
lookup_field
=
'key'
lookup_field
=
'key'
permission_classes
=
(
IsAuthenticated
,)
permission_classes
=
(
IsAuthenticated
,)
...
@@ -397,9 +397,41 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
...
@@ -397,9 +397,41 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
paramType: query
paramType: query
type: string
type: string
required: false
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
)
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
):
class
CourseSearchViewSet
(
BaseCourseHaystackViewSet
):
facet_serializer_class
=
serializers
.
CourseFacetSerializer
facet_serializer_class
=
serializers
.
CourseFacetSerializer
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment