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
91453d0d
Commit
91453d0d
authored
Jul 01, 2016
by
Clinton Blackburn
Committed by
GitHub
Jul 01, 2016
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #144 from edx/clintonb/availability-facet
Availability facet
parents
ef4b10b7
e4f67070
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
206 additions
and
14 deletions
+206
-14
course_discovery/apps/api/filters.py
+14
-0
course_discovery/apps/api/serializers.py
+89
-10
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 @
91453d0d
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 @
91453d0d
# pylint: disable=abstract-method
# pylint: disable=abstract-method
import
datetime
from
urllib.parse
import
urlencode
from
urllib.parse
import
urlencode
from
django.contrib.auth
import
get_user_model
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
(
...
@@ -28,15 +28,16 @@ COURSE_RUN_FACET_FIELD_OPTIONS = {
...
@@ -28,15 +28,16 @@ COURSE_RUN_FACET_FIELD_OPTIONS = {
'language'
:
{},
'language'
:
{},
'transcript_languages'
:
{},
'transcript_languages'
:
{},
'pacing_type'
:
{},
'pacing_type'
:
{},
'start'
:
{
'start_date'
:
datetime
.
datetime
.
now
()
-
datetime
.
timedelta
(
days
=
365
),
'end_date'
:
datetime
.
datetime
.
now
(),
'gap_by'
:
'month'
,
'gap_amount'
:
1
,
},
'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'
,
...
@@ -366,6 +367,82 @@ class FlattenedCourseRunWithCourseSerializer(CourseRunSerializer):
...
@@ -366,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'
)
...
@@ -376,7 +453,7 @@ class CourseSearchSerializer(HaystackSerializer):
...
@@ -376,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
:
...
@@ -400,12 +477,13 @@ class CourseRunSearchSerializer(HaystackSerializer):
...
@@ -400,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
...
@@ -420,12 +498,13 @@ class AggregateSearchSerializer(HaystackSerializer):
...
@@ -420,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 @
91453d0d
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 @
91453d0d
...
@@ -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