Commit 24491028 by Clinton Blackburn Committed by GitHub

Added endpoints to read organizations (#469)

* Removed management API views

These views have not been used in a while and are not a part of the official API.

ECOM-6501

* Reorganized API views

Moved the views into resource-specific modules.

ECOM-6501

* Added endpoints to list and retrieve organizations

ECOM-6501
parent cfa25cee
......@@ -13,7 +13,7 @@ from rest_framework.exceptions import PermissionDenied, NotFound
from course_discovery.apps.api.utils import cast2int
from course_discovery.apps.core.models import Partner
from course_discovery.apps.course_metadata.models import Course, CourseRun, Program
from course_discovery.apps.course_metadata.models import Course, CourseRun, Program, Organization
logger = logging.getLogger(__name__)
User = get_user_model()
......@@ -97,6 +97,8 @@ class HaystackFilter(HaystackRequestFilterMixin, DefaultHaystackFilter):
class CharListFilter(django_filters.CharFilter):
""" Filters a field via a comma-delimited list of values. """
def filter(self, qs, value): # pylint: disable=method-hidden
if value not in (None, ''):
value = value.split(',')
......@@ -104,6 +106,15 @@ class CharListFilter(django_filters.CharFilter):
return super(CharListFilter, self).filter(qs, value)
class UUIDListFilter(CharListFilter):
""" Filters a field via a comma-delimited list of UUIDs. """
def __init__(self, name='uuid', label=None, widget=None, action=None,
lookup_expr='in', required=False, distinct=False, exclude=False, **kwargs):
super().__init__(name=name, label=label, widget=widget, action=action, lookup_expr=lookup_expr,
required=required, distinct=distinct, exclude=exclude, **kwargs)
class FilterSetMixin:
def _apply_filter(self, name, queryset, value):
return getattr(queryset, name)() if cast2int(value, name) else queryset
......@@ -116,7 +127,7 @@ class FilterSetMixin:
class CourseFilter(django_filters.FilterSet):
keys = CharListFilter(name='key', lookup_type='in')
keys = CharListFilter(name='key', lookup_expr='in')
class Meta:
model = Course
......@@ -126,7 +137,7 @@ class CourseFilter(django_filters.FilterSet):
class CourseRunFilter(FilterSetMixin, django_filters.FilterSet):
active = django_filters.MethodFilter()
marketable = django_filters.MethodFilter()
keys = CharListFilter(name='key', lookup_type='in')
keys = CharListFilter(name='key', lookup_expr='in')
@property
def qs(self):
......@@ -145,8 +156,15 @@ class CourseRunFilter(FilterSetMixin, django_filters.FilterSet):
class ProgramFilter(FilterSetMixin, django_filters.FilterSet):
marketable = django_filters.MethodFilter()
type = django_filters.CharFilter(name='type__name', lookup_expr='iexact')
uuids = CharListFilter(name='uuid', lookup_type='in')
uuids = UUIDListFilter()
class Meta:
model = Program
fields = ['type', 'uuids']
class OrganizationFilter(django_filters.FilterSet):
tags = CharListFilter(name='tags__name', lookup_expr='in')
uuids = UUIDListFilter()
class Meta:
model = Organization
......@@ -9,7 +9,7 @@ from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.serializers import (
CatalogSerializer, CourseWithProgramsSerializer, CourseSerializerExcludingClosedRuns,
CourseRunWithProgramsSerializer, MinimalProgramSerializer, ProgramSerializer,
FlattenedCourseRunWithCourseSerializer,
FlattenedCourseRunWithCourseSerializer, OrganizationSerializer
)
......@@ -56,6 +56,9 @@ class SerializationMixin(object):
def serialize_catalog_flat_course_run(self, course_run, many=False, format=None, extra_context=None):
return self._serialize_object(FlattenedCourseRunWithCourseSerializer, course_run, many, format, extra_context)
def serialize_organization(self, organization, many=False, format=None, extra_context=None):
return self._serialize_object(OrganizationSerializer, organization, many, format, extra_context)
class OAuth2Mixin(object):
def generate_oauth2_token_header(self, user):
......
import mock
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from course_discovery.apps.core.tests.factories import UserFactory
class ManagementCommandViewTestMixin(object):
call_command_path = None
command_name = None
path = None
def setUp(self):
super(ManagementCommandViewTestMixin, self).setUp()
self.superuser = UserFactory(is_superuser=True)
self.client.force_authenticate(self.superuser) # pylint: disable=no-member
def assert_access_forbidden(self):
""" Asserts that a call to the endpoint fails with HTTP status 403. """
response = self.client.post(self.path)
self.assertEqual(response.status_code, 403)
def test_non_superusers_denied(self):
""" Verify access is denied to non-superusers. """
# Anonymous user
self.client.logout()
self.assert_access_forbidden()
# Normal and staff users
users = (UserFactory(), UserFactory(is_staff=True),)
for user in users:
self.client.force_authenticate(user) # pylint: disable=no-member
self.assert_access_forbidden()
def test_success_response(self):
""" Verify a successful response calls the management command and returns the plain text output. """
self.assert_successful_response()
self.assert_successful_response('abc123')
def assert_successful_response(self, access_token=None):
""" Asserts the endpoint called the correct management command with the correct arguments, and the endpoint
returns HTTP 200 with text/plain content type. """
data = {'access_token': access_token} if access_token else None
with mock.patch(self.call_command_path, return_value=None) as mocked_call_command:
response = self.client.post(self.path, data)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content_type, 'text/plain')
args, kwargs = mocked_call_command.call_args
expected = {
'settings': 'course_discovery.settings.test'
}
self.assertTrue(mocked_call_command.called)
self.assertEqual(args[0], self.command_name)
self.assertDictContainsSubset(expected, kwargs)
class UpdateIndexTests(ManagementCommandViewTestMixin, APITestCase):
""" Tests for the update_index management endpoint. """
call_command_path = 'course_discovery.apps.api.v1.views.call_command'
command_name = 'update_index'
path = reverse('api:v1:management-update-index')
import uuid
import ddt
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from course_discovery.apps.api.v1.tests.test_views.mixins import SerializationMixin
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory, Organization
@ddt.ddt
class OrganizationViewSetTests(SerializationMixin, APITestCase):
list_path = reverse('api:v1:organization-list')
def setUp(self):
super(OrganizationViewSetTests, self).setUp()
self.user = UserFactory(is_staff=True, is_superuser=True)
self.client.login(username=self.user.username, password=USER_PASSWORD)
def test_authentication(self):
""" Verify the endpoint requires the user to be authenticated. """
response = self.client.get(self.list_path)
self.assertEqual(response.status_code, 200)
self.client.logout()
response = self.client.get(self.list_path)
self.assertEqual(response.status_code, 403)
def assert_response_data_valid(self, response, organizations, many=True):
""" Asserts the response data (only) contains the expected organizations. """
actual = response.data
if many:
actual = actual['results']
self.assertEqual(actual, self.serialize_organization(organizations, many=many))
def assert_list_uuid_filter(self, organizations):
""" Asserts the list endpoint supports filtering by UUID. """
with self.assertNumQueries(5):
uuids = ','.join([organization.uuid.hex for organization in organizations])
url = '{root}?uuids={uuids}'.format(root=self.list_path, uuids=uuids)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assert_response_data_valid(response, organizations)
def assert_list_tag_filter(self, organizations, tags, expected_query_count=5):
""" Asserts the list endpoint supports filtering by tags. """
with self.assertNumQueries(expected_query_count):
tags = ','.join(tags)
url = '{root}?tags={tags}'.format(root=self.list_path, tags=tags)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assert_response_data_valid(response, organizations)
def test_list(self):
""" Verify the endpoint returns a list of all organizations. """
OrganizationFactory.create_batch(3)
with self.assertNumQueries(6):
response = self.client.get(self.list_path)
self.assertEqual(response.status_code, 200)
self.assert_response_data_valid(response, Organization.objects.all())
def test_list_uuid_filter(self):
""" Verify the endpoint returns a list of organizations filtered by UUID. """
organizations = OrganizationFactory.create_batch(3)
# Test with a single UUID
self.assert_list_uuid_filter([organizations[0]])
# Test with multiple UUIDs
self.assert_list_uuid_filter(organizations)
def test_list_tag_filter(self):
""" Verify the endpoint returns a list of organizations filtered by tag. """
tag = 'test-org'
organizations = OrganizationFactory.create_batch(2)
# If no organizations have been tagged, the endpoint should not return any data
self.assert_list_tag_filter([], [tag], expected_query_count=4)
# Tagged organizations should be returned
organizations[0].tags.add(tag)
self.assert_list_tag_filter([organizations[0]], [tag])
# The endpoint should support filtering by multiple tags. The filter should be an OR filter, meaning the results
# include any organization containing at least one of the given tags.
tag2 = 'another-tag'
organizations[1].tags.add(tag)
self.assert_list_tag_filter(Organization.objects.all(), [tag, tag2])
def test_retrieve(self):
""" Verify the endpoint returns details for a single organization. """
organization = OrganizationFactory()
url = reverse('api:v1:organization-detail', kwargs={'uuid': organization.uuid})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assert_response_data_valid(response, organization, many=False)
@ddt.data(123, uuid.uuid4())
def test_retrieve_not_found(self, organization_uuid):
""" Verify the endpoint returns HTTP 404 if the specified UUID does not match an organization. """
url = reverse('api:v1:organization-detail', kwargs={'uuid': organization_uuid})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
import ddt
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase, APIRequestFactory
from rest_framework.test import APITestCase
from course_discovery.apps.api.serializers import MinimalProgramSerializer
from course_discovery.apps.api.v1.views import ProgramViewSet
from course_discovery.apps.api.v1.tests.test_views.mixins import SerializationMixin
from course_discovery.apps.api.v1.views.programs import ProgramViewSet
from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory
from course_discovery.apps.core.tests.helpers import make_image_file
from course_discovery.apps.course_metadata.choices import ProgramStatus
......@@ -23,8 +23,6 @@ class ProgramViewSetTests(SerializationMixin, APITestCase):
super(ProgramViewSetTests, self).setUp()
self.user = UserFactory(is_staff=True, is_superuser=True)
self.client.login(username=self.user.username, password=USER_PASSWORD)
self.request = APIRequestFactory().get('/')
self.request.user = self.user
def create_program(self):
organizations = [OrganizationFactory()]
......
......@@ -8,9 +8,11 @@ from django.core.urlresolvers import reverse
from haystack.query import SearchQuerySet
from rest_framework.test import APITestCase
from course_discovery.apps.api.serializers import (CourseRunSearchSerializer, ProgramSearchSerializer,
TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer)
from course_discovery.apps.api.v1.views import TypeaheadSearchView
from course_discovery.apps.api.serializers import (
CourseRunSearchSerializer, ProgramSearchSerializer, TypeaheadCourseRunSearchSerializer,
TypeaheadProgramSearchSerializer
)
from course_discovery.apps.api.v1.views.search import TypeaheadSearchView
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD, PartnerFactory
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
......
......@@ -2,25 +2,31 @@
from django.conf.urls import include, url
from rest_framework import routers
from course_discovery.apps.api.v1 import views
from course_discovery.apps.api.v1.views import search as search_views
from course_discovery.apps.api.v1.views.affiliates import AffiliateWindowViewSet
from course_discovery.apps.api.v1.views.catalogs import CatalogViewSet
from course_discovery.apps.api.v1.views.course_runs import CourseRunViewSet
from course_discovery.apps.api.v1.views.courses import CourseViewSet
from course_discovery.apps.api.v1.views.organizations import OrganizationViewSet
from course_discovery.apps.api.v1.views.programs import ProgramViewSet
partners_router = routers.SimpleRouter()
partners_router.register(r'affiliate_window/catalogs', views.AffiliateWindowViewSet, base_name='affiliate_window')
partners_urls = partners_router.urls
partners_router.register(r'affiliate_window/catalogs', AffiliateWindowViewSet, base_name='affiliate_window')
urlpatterns = [
url(r'^partners/', include(partners_urls, namespace='partners')),
url(r'search/typeahead', views.TypeaheadSearchView.as_view(), name='search-typeahead')
url(r'^partners/', include(partners_router.urls, namespace='partners')),
url(r'search/typeahead', search_views.TypeaheadSearchView.as_view(), name='search-typeahead')
]
router = routers.SimpleRouter()
router.register(r'catalogs', views.CatalogViewSet)
router.register(r'courses', views.CourseViewSet, base_name='course')
router.register(r'course_runs', views.CourseRunViewSet, base_name='course_run')
router.register(r'management', views.ManagementViewSet, base_name='management')
router.register(r'programs', views.ProgramViewSet, base_name='program')
router.register(r'search/all', views.AggregateSearchViewSet, base_name='search-all')
router.register(r'search/courses', views.CourseSearchViewSet, base_name='search-courses')
router.register(r'search/course_runs', views.CourseRunSearchViewSet, base_name='search-course_runs')
router.register(r'search/programs', views.ProgramSearchViewSet, base_name='search-programs')
router.register(r'catalogs', CatalogViewSet)
router.register(r'courses', CourseViewSet, base_name='course')
router.register(r'course_runs', CourseRunViewSet, base_name='course_run')
router.register(r'organizations', OrganizationViewSet, base_name='organization')
router.register(r'programs', ProgramViewSet, base_name='program')
router.register(r'search/all', search_views.AggregateSearchViewSet, base_name='search-all')
router.register(r'search/courses', search_views.CourseSearchViewSet, base_name='search-courses')
router.register(r'search/course_runs', search_views.CourseRunSearchViewSet, base_name='search-course_runs')
router.register(r'search/programs', search_views.ProgramSearchViewSet, base_name='search-programs')
urlpatterns += router.urls
import logging
from django.contrib.auth import get_user_model
from course_discovery.apps.api import serializers
from course_discovery.apps.api.utils import cast2int
logger = logging.getLogger(__name__)
User = get_user_model()
def get_query_param(request, name):
"""
Get a query parameter and cast it to an integer.
"""
return cast2int(request.query_params.get(name), name)
def prefetch_related_objects_for_courses(queryset):
"""
Pre-fetches the related objects that will be serialized with a `Course`.
Pre-fetching allows us to consolidate our database queries rather than run
thousands of queries as we serialize the data. For details, see the links below:
- https://docs.djangoproject.com/en/1.10/ref/models/querysets/#select-related
- https://docs.djangoproject.com/en/1.10/ref/models/querysets/#prefetch-related
Args:
queryset (QuerySet): original query
Returns:
QuerySet
"""
_prefetch_fields = serializers.PREFETCH_FIELDS
_select_related_fields = serializers.SELECT_RELATED_FIELDS
# Prefetch the data for the related course runs
course_run_prefetch_fields = _prefetch_fields['course_run'] + _select_related_fields['course_run']
course_run_prefetch_fields = ['course_runs__' + field for field in course_run_prefetch_fields]
queryset = queryset.prefetch_related(*course_run_prefetch_fields)
queryset = queryset.select_related(*_select_related_fields['course'])
queryset = queryset.prefetch_related(*_prefetch_fields['course'])
return queryset
from django.shortcuts import get_object_or_404
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from course_discovery.apps.api import serializers
from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.course_metadata.models import CourseRun, Seat
class AffiliateWindowViewSet(viewsets.ViewSet):
""" AffiliateWindow Resource. """
permission_classes = (IsAuthenticated,)
renderer_classes = (AffiliateWindowXMLRenderer,)
serializer_class = serializers.AffiliateWindowSerializer
def retrieve(self, request, pk=None): # pylint: disable=redefined-builtin,unused-argument
"""
Return verified and professional seats of courses against provided catalog id.
---
produces:
- application/xml
"""
catalog = get_object_or_404(Catalog, pk=pk)
if not catalog.has_object_read_permission(request):
raise PermissionDenied
courses = catalog.courses()
course_runs = CourseRun.objects.filter(course__in=courses).active().marketable()
seats = Seat.objects.filter(type__in=[Seat.VERIFIED, Seat.PROFESSIONAL]).filter(course_run__in=course_runs)
seats = seats.select_related('course_run').prefetch_related('course_run__course', 'course_run__course__partner')
serializer = serializers.AffiliateWindowSerializer(seats, many=True)
return Response(serializer.data)
import datetime
from django.db import transaction
from django.http import HttpResponse
from dry_rest_permissions.generics import DRYPermissions
from rest_framework import viewsets, status
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.renderers import CourseRunCSVRenderer
from course_discovery.apps.api.v1.views import User, prefetch_related_objects_for_courses
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.course_metadata.models import CourseRun
# pylint: disable=no-member
class CatalogViewSet(viewsets.ModelViewSet):
""" Catalog resource. """
filter_backends = (filters.PermissionsFilter,)
lookup_field = 'id'
permission_classes = (DRYPermissions,)
queryset = Catalog.objects.all()
serializer_class = serializers.CatalogSerializer
@transaction.atomic
def create(self, request, *args, **kwargs):
""" Create a new catalog. """
data = request.data.copy()
usernames = request.data.get('viewers', ())
# Add support for parsing a comma-separated list from Swagger
if isinstance(usernames, str):
usernames = usernames.split(',')
data.setlist('viewers', usernames)
# Ensure the users exist
for username in usernames:
User.objects.get_or_create(username=username)
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, *args, **kwargs):
""" Destroy a catalog. """
return super(CatalogViewSet, self).destroy(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
""" Retrieve a list of all catalogs.
---
parameters:
- name: username
description: User whose catalogs should be retrieved.
required: false
type: string
paramType: query
multiple: false
"""
return super(CatalogViewSet, self).list(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
""" Update one, or more, fields for a catalog. """
return super(CatalogViewSet, self).partial_update(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
""" Retrieve details for a catalog. """
return super(CatalogViewSet, self).retrieve(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
""" Update a catalog. """
return super(CatalogViewSet, self).update(request, *args, **kwargs)
@detail_route()
def courses(self, request, id=None): # pylint: disable=redefined-builtin,unused-argument
"""
Retrieve the list of courses contained within this catalog.
Only courses with active course runs are returned. A course run is considered active if it is currently
open for enrollment, or will open in the future.
---
serializer: serializers.CourseSerializerExcludingClosedRuns
"""
catalog = self.get_object()
queryset = catalog.courses().active()
queryset = prefetch_related_objects_for_courses(queryset)
page = self.paginate_queryset(queryset)
serializer = serializers.CourseSerializerExcludingClosedRuns(page, many=True, context={'request': request})
return self.get_paginated_response(serializer.data)
@detail_route()
def contains(self, request, id=None): # pylint: disable=redefined-builtin,unused-argument
"""
Determine if this catalog contains the provided courses.
A dictionary mapping course IDs to booleans, indicating course presence, will be returned.
---
serializer: serializers.ContainedCoursesSerializer
parameters:
- name: course_id
description: Course IDs to check for existence in the Catalog.
required: true
type: string
paramType: query
multiple: true
"""
course_ids = request.query_params.get('course_id')
course_ids = course_ids.split(',')
catalog = self.get_object()
courses = catalog.contains(course_ids)
instance = {'courses': courses}
serializer = serializers.ContainedCoursesSerializer(instance)
return Response(serializer.data)
@detail_route()
def csv(self, request, id=None): # pylint: disable=redefined-builtin,unused-argument
"""
Retrieve a CSV containing the course runs contained within this catalog.
Only active course runs are returned. A course run is considered active if it is currently
open for enrollment, or will be open for enrollment in the future.
---
serializer: serializers.FlattenedCourseRunWithCourseSerializer
"""
catalog = self.get_object()
courses = catalog.courses()
course_runs = CourseRun.objects.filter(course__in=courses).active().marketable()
# We use select_related and prefetch_related to decrease our database query count
course_runs = course_runs.select_related(*serializers.SELECT_RELATED_FIELDS['course_run'])
prefetch_fields = ['course__' + field for field in serializers.PREFETCH_FIELDS['course']]
prefetch_fields += serializers.PREFETCH_FIELDS['course_run']
course_runs = course_runs.prefetch_related(*prefetch_fields)
serializer = serializers.FlattenedCourseRunWithCourseSerializer(
course_runs, many=True, context={'request': request}
)
data = CourseRunCSVRenderer().render(serializer.data)
response = HttpResponse(data, content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="catalog_{id}_{date}.csv"'.format(
id=id, date=datetime.datetime.utcnow().strftime('%Y-%m-%d-%H-%M')
)
return response
from django.conf import settings
from django.db.models.functions import Lower
from rest_framework import viewsets, status
from rest_framework.decorators import list_route
from rest_framework.filters import DjangoFilterBackend, OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.exceptions import InvalidPartnerError
from course_discovery.apps.api.v1.views import get_query_param
from course_discovery.apps.core.models import Partner
from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.constants import COURSE_RUN_ID_REGEX
from course_discovery.apps.course_metadata.models import CourseRun
# pylint: disable=no-member
class CourseRunViewSet(viewsets.ReadOnlyModelViewSet):
""" CourseRun resource. """
filter_backends = (DjangoFilterBackend, OrderingFilter)
filter_class = filters.CourseRunFilter
lookup_field = 'key'
lookup_value_regex = COURSE_RUN_ID_REGEX
ordering_fields = ('start',)
permission_classes = (IsAuthenticated,)
queryset = CourseRun.objects.all().order_by(Lower('key'))
serializer_class = serializers.CourseRunWithProgramsSerializer
def _get_partner(self):
""" Return the partner for the code passed in or the default partner """
partner_code = self.request.query_params.get('partner')
if partner_code:
try:
partner = Partner.objects.get(short_code=partner_code)
except Partner.DoesNotExist:
raise InvalidPartnerError('Unknown Partner')
else:
partner = Partner.objects.get(id=settings.DEFAULT_PARTNER_ID)
return partner
def get_queryset(self):
""" List one course run
---
parameters:
- name: include_deleted_programs
description: Will include deleted programs in the associated programs array
required: false
type: integer
paramType: query
multiple: false
"""
q = self.request.query_params.get('q', None)
partner = self._get_partner()
if q:
qs = SearchQuerySetWrapper(CourseRun.search(q).filter(partner=partner.short_code))
# This is necessary to avoid issues with the filter backend.
qs.model = self.queryset.model
return qs
else:
queryset = super(CourseRunViewSet, self).get_queryset().filter(course__partner=partner)
queryset = queryset.select_related(*serializers.SELECT_RELATED_FIELDS['course_run'])
queryset = queryset.prefetch_related(*serializers.PREFETCH_FIELDS['course_run'])
return queryset
def get_serializer_context(self, *args, **kwargs):
context = super().get_serializer_context(*args, **kwargs)
context.update({
'exclude_utm': get_query_param(self.request, 'exclude_utm'),
'include_deleted_programs': get_query_param(self.request, 'include_deleted_programs'),
'include_unpublished_programs': get_query_param(self.request, 'include_unpublished_programs'),
})
return context
def list(self, request, *args, **kwargs):
""" List all courses runs.
---
parameters:
- name: q
description: Elasticsearch querystring query. This filter takes precedence over other filters.
required: false
type: string
paramType: query
multiple: false
- name: keys
description: Filter by keys (comma-separated list)
required: false
type: string
paramType: query
multiple: false
- name: partner
description: Filter by partner
required: false
type: string
paramType: query
multiple: false
- name: active
description: Retrieve active course runs. A course is considered active if its end date has not passed,
and it is open for enrollment.
required: false
type: integer
paramType: query
multiple: false
- name: marketable
description: Retrieve marketable course runs. A course run is considered marketable if it has a
marketing slug.
required: false
type: integer
paramType: query
multiple: false
- name: exclude_utm
description: Exclude UTM parameters from marketing URLs.
required: false
type: integer
paramType: query
multiple: false
- name: include_deleted_programs
description: Will include deleted programs in the associated programs array
required: false
type: integer
paramType: query
multiple: false
- name: include_unpublished_programs
description: Will include unpublished programs in the associated programs array
required: false
type: integer
paramType: query
multiple: false
"""
return super(CourseRunViewSet, self).list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
""" Retrieve details for a course run. """
return super(CourseRunViewSet, self).retrieve(request, *args, **kwargs)
@list_route()
def contains(self, request):
"""
Determine if course runs are found in the query results.
A dictionary mapping course run keys to booleans,
indicating course run presence, will be returned.
---
serializer: serializers.ContainedCourseRunsSerializer
parameters:
- name: query
description: Elasticsearch querystring query
required: true
type: string
paramType: query
multiple: false
- name: course_run_ids
description: Comma-separated list of course run IDs
required: true
type: string
paramType: query
multiple: true
- name: partner
description: Filter by partner
required: false
type: string
paramType: query
multiple: false
"""
query = request.GET.get('query')
course_run_ids = request.GET.get('course_run_ids')
partner = self._get_partner()
if query and course_run_ids:
course_run_ids = course_run_ids.split(',')
course_runs = CourseRun.search(query).filter(partner=partner.short_code).filter(key__in=course_run_ids). \
values_list('key', flat=True)
contains = {course_run_id: course_run_id in course_runs for course_run_id in course_run_ids}
instance = {'course_runs': contains}
serializer = serializers.ContainedCourseRunsSerializer(instance)
return Response(serializer.data)
return Response(status=status.HTTP_400_BAD_REQUEST)
from django.db.models.functions import Lower
from rest_framework import viewsets
from rest_framework.filters import DjangoFilterBackend
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.v1.views import prefetch_related_objects_for_courses, get_query_param
from course_discovery.apps.course_metadata.constants import COURSE_ID_REGEX
from course_discovery.apps.course_metadata.models import Course
# pylint: disable=no-member
class CourseViewSet(viewsets.ReadOnlyModelViewSet):
""" Course resource. """
filter_backends = (DjangoFilterBackend,)
filter_class = filters.CourseFilter
lookup_field = 'key'
lookup_value_regex = COURSE_ID_REGEX
queryset = Course.objects.all()
permission_classes = (IsAuthenticated,)
serializer_class = serializers.CourseWithProgramsSerializer
def get_queryset(self):
""" List one course
---
parameters:
- name: include_deleted_programs
description: Will include deleted programs in the associated programs array
required: false
type: integer
paramType: query
multiple: false
"""
q = self.request.query_params.get('q', None)
if q:
queryset = Course.search(q)
else:
queryset = super(CourseViewSet, self).get_queryset()
queryset = prefetch_related_objects_for_courses(queryset)
return queryset.order_by(Lower('key'))
def get_serializer_context(self, *args, **kwargs):
context = super().get_serializer_context(*args, **kwargs)
context.update({
'exclude_utm': get_query_param(self.request, 'exclude_utm'),
'include_deleted_programs': get_query_param(self.request, 'include_deleted_programs'),
})
return context
def list(self, request, *args, **kwargs):
""" List all courses.
---
parameters:
- name: q
description: Elasticsearch querystring query. This filter takes precedence over other filters.
required: false
type: string
paramType: query
multiple: false
- name: keys
description: Filter by keys (comma-separated list)
required: false
type: string
paramType: query
multiple: false
- name: exclude_utm
description: Exclude UTM parameters from marketing URLs.
required: false
type: integer
paramType: query
multiple: false
- name: include_deleted_programs
description: Will include deleted programs in the associated programs array
required: false
type: integer
paramType: query
multiple: false
"""
return super(CourseViewSet, self).list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
""" Retrieve details for a course. """
return super(CourseViewSet, self).retrieve(request, *args, **kwargs)
from rest_framework import viewsets
from rest_framework.filters import DjangoFilterBackend
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.api import filters, serializers
# pylint: disable=no-member
class OrganizationViewSet(viewsets.ReadOnlyModelViewSet):
""" Organization resource. """
filter_backends = (DjangoFilterBackend,)
filter_class = filters.OrganizationFilter
lookup_field = 'uuid'
lookup_value_regex = '[0-9a-f-]+'
permission_classes = (IsAuthenticated,)
queryset = serializers.OrganizationSerializer.prefetch_queryset()
serializer_class = serializers.OrganizationSerializer
def list(self, request, *args, **kwargs):
""" Retrieve a list of all organizations. """
return super(OrganizationViewSet, self).list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
""" Retrieve details for an organization. """
return super(OrganizationViewSet, self).retrieve(request, *args, **kwargs)
from rest_framework import viewsets
from rest_framework.filters import DjangoFilterBackend
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.v1.views import get_query_param
# pylint: disable=no-member
class ProgramViewSet(viewsets.ReadOnlyModelViewSet):
""" Program resource. """
lookup_field = 'uuid'
lookup_value_regex = '[0-9a-f-]+'
permission_classes = (IsAuthenticated,)
filter_backends = (DjangoFilterBackend,)
filter_class = filters.ProgramFilter
def get_serializer_class(self):
if self.action == 'list':
return serializers.MinimalProgramSerializer
return serializers.ProgramSerializer
def get_queryset(self):
# This method prevents prefetches on the program queryset from "stacking,"
# which happens when the queryset is stored in a class property.
return self.get_serializer_class().prefetch_queryset()
def get_serializer_context(self, *args, **kwargs):
context = super().get_serializer_context(*args, **kwargs)
context.update({
'published_course_runs_only': get_query_param(self.request, 'published_course_runs_only'),
'exclude_utm': get_query_param(self.request, 'exclude_utm'),
})
return context
def list(self, request, *args, **kwargs):
""" List all programs.
---
parameters:
- name: partner
description: Filter by partner
required: false
type: string
paramType: query
multiple: false
- name: marketable
description: Retrieve marketable programs. A program is considered marketable if it is active
and has a marketing slug.
required: false
type: integer
paramType: query
multiple: false
- name: published_course_runs_only
description: Filter course runs by published ones only
required: false
type: integer
paramType: query
mulitple: false
- name: exclude_utm
description: Exclude UTM parameters from marketing URLs.
required: false
type: integer
paramType: query
multiple: false
"""
return super(ProgramViewSet, self).list(request, *args, **kwargs)
from drf_haystack.mixins import FacetMixin
from drf_haystack.viewsets import HaystackViewSet
from haystack.backends import SQ
from haystack.inputs import AutoQuery
from haystack.query import SearchQuerySet
from rest_framework import status
from rest_framework.decorators import list_route
from rest_framework.exceptions import ParseError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import PageNumberPagination
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import Course, CourseRun, Program
class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
document_uid_field = 'key'
facet_filter_backends = [filters.HaystackFacetFilterWithQueries, filters.HaystackFilter]
load_all = True
lookup_field = 'key'
permission_classes = (IsAuthenticated,)
# NOTE: We use PageNumberPagination because drf-haystack's facet serializer relies on the page_query_param
# attribute, and it is more appropriate for search results than our default limit-offset pagination.
pagination_class = PageNumberPagination
def list(self, request, *args, **kwargs):
"""
Search.
---
parameters:
- name: q
description: Search text
paramType: query
type: string
required: false
"""
return super(BaseHaystackViewSet, self).list(request, *args, **kwargs)
@list_route(methods=['get'], url_path='facets')
def facets(self, request):
"""
Returns faceted search results
---
parameters:
- name: q
description: Search text
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(BaseHaystackViewSet, self).facets(request)
def filter_facet_queryset(self, queryset):
queryset = super().filter_facet_queryset(queryset)
q = self.request.query_params.get('q')
if q:
queryset = queryset.filter(SQ(text=AutoQuery(q)) | SQ(title=AutoQuery(q)))
facet_serializer_cls = self.get_facet_serializer_class()
field_queries = getattr(facet_serializer_cls.Meta, 'field_queries', {})
# Ensure we only return published, non-hidden items
queryset = queryset.filter(published=True).exclude(hidden=True)
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(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseFacetSerializer
index_models = (Course,)
serializer_class = serializers.CourseSearchSerializer
class CourseRunSearchViewSet(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseRunFacetSerializer
index_models = (CourseRun,)
serializer_class = serializers.CourseRunSearchSerializer
class ProgramSearchViewSet(BaseHaystackViewSet):
document_uid_field = 'uuid'
lookup_field = 'uuid'
facet_serializer_class = serializers.ProgramFacetSerializer
index_models = (Program,)
serializer_class = serializers.ProgramSearchSerializer
class AggregateSearchViewSet(BaseHaystackViewSet):
""" Search all content types. """
facet_serializer_class = serializers.AggregateFacetSearchSerializer
serializer_class = serializers.AggregateSearchSerializer
class TypeaheadSearchView(APIView):
"""
Typeahead for courses and programs.
"""
RESULT_COUNT = 3
permission_classes = (IsAuthenticated,)
def get_results(self, query):
query = '(title:*{query}* OR course_key:*{query}*)'.format(query=query.lower())
course_runs = SearchQuerySet().models(CourseRun).raw_search(query)
course_runs = course_runs.filter(published=True).exclude(hidden=True)
course_runs = course_runs[:self.RESULT_COUNT]
programs = SearchQuerySet().models(Program).raw_search(query)
programs = programs.filter(status=ProgramStatus.Active)
programs = programs[:self.RESULT_COUNT]
return course_runs, programs
def get(self, request, *args, **kwargs):
query = request.query_params.get('q')
if not query:
raise ParseError("The 'q' querystring parameter is required for searching.")
course_runs, programs = self.get_results(query)
data = {'course_runs': course_runs, 'programs': programs}
serializer = serializers.TypeaheadSearchSerializer(data)
return Response(serializer.data, status=status.HTTP_200_OK)
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