Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lms/djangoapps/appsembler_api/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.forms import CharField

from lms.djangoapps.course_api.forms import CourseListGetForm


class CourseListGetAndSearchForm(CourseListGetForm):
"""
Similar to CourseListGetForm but with additional search argument.
"""

search_term = CharField(required=False)
3 changes: 3 additions & 0 deletions lms/djangoapps/appsembler_api/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Tests for the Appsembler API django app.
"""
119 changes: 119 additions & 0 deletions lms/djangoapps/appsembler_api/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""
Tests for the Appsembler API views.
"""

from urllib import quote, urlencode

from django.core.urlresolvers import reverse

from lms.djangoapps.course_api.tests.test_views import CourseApiTestViewMixin
import json
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase

from django.test.utils import override_settings

from search.tests.tests import TEST_INDEX_NAME
from search.tests.test_course_discovery import DemoCourse
from search.tests.utils import SearcherMixin


# Any class that inherits from TestCase will cause too-many-public-methods pylint error
# pylint: disable=too-many-public-methods
@override_settings(ELASTIC_FIELD_MAPPINGS={ # pylint: disable=too-many-ancestors
"start_date": {"type": "date"},
"enrollment_start": {"type": "date"},
"enrollment_end": {"type": "date"}
})
@override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine")
@override_settings(COURSEWARE_INDEX_NAME=TEST_INDEX_NAME)
class CourseListSearchViewTest(CourseApiTestViewMixin, ModuleStoreTestCase, SearcherMixin):
"""
Similar to search.tests.test_course_discovery_views but with the course API integration.
"""

def setUp(self):
super(CourseListSearchViewTest, self).setUp()
DemoCourse.reset_count()
self.searcher.destroy()

self.courses = [
self.add_course("OrgA", "Find this one with the right parameter"),
self.add_course("OrgB", "Find this one with another parameter"),
self.add_course("OrgC", "Find this one somehow"),
]

self.url = reverse('course-list')
self.staff_user = self.create_user(username='staff', is_staff=True)
self.honor_user = self.create_user(username='honor', is_staff=False)

def add_course(self, org_code, short_description):
"""
Add a course to both database and search.

Warning: A ton of gluing here! If this fails, double check both CourseListViewTestCase and MockSearchUrlTest.
"""

search_course = DemoCourse.get({
"org": org_code,
"run": "2010",
"number": "DemoZ",
"id": "{org_code}/DemoZ/2010".format(org_code=org_code),
"content": {
"short_description": short_description,
},
})

DemoCourse.index(self.searcher, [search_course])

org, course, run = search_course['id'].split('/')

db_course = self.create_course(
org=org,
course=course,
run=run,
short_description=short_description,
)

return db_course

def search_request(self, search_term=''):
res = self.client.get(reverse("course_list_search"), data={'search_term': search_term})
return res.status_code, json.loads(res.content)

def test_search_api_alone(self):
"""
Double check that search alone works fine.
"""
res = self.client.post(reverse('course_discovery'))
data = json.loads(res.content)
self.assertNotEqual(data["results"], [])
self.assertNotIn('course-v1', unicode(self.courses[0].id))
self.assertContains(res, unicode(self.courses[0].id))
self.assertEqual(data["total"], 3)

def test_course_api_alone(self):
"""
Double check that search alone works fine.
"""
self.setup_user(self.staff_user)
response = self.verify_response(expected_status_code=200, params={'username': self.staff_user.username})
data = json.loads(response.content)
self.assertNotEqual(data["results"], [])
self.assertEqual(data["pagination"]["count"], 3)
self.assertNotIn('course-v1', response.content)

def test_list_all(self):
""" test searching using the url """
code, data = self.search_request()
self.assertEqual(200, code)
self.assertIn("results", data)
self.assertNotEqual(data["results"], [])
self.assertEqual(data["pagination"]["count"], 3)

def test_list_all_with_search_term(self):
""" test searching using the url """
code, data = self.search_request(search_term='somehow')
self.assertEqual(200, code)
self.assertIn("results", data)
self.assertNotEqual(data["results"], [])
self.assertEqual(data["pagination"]["count"], 1)
3 changes: 3 additions & 0 deletions lms/djangoapps/appsembler_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
url(r'^accounts/update_user', views.UpdateUserAccount.as_view(), name="user_account_update_user"),
url(r'^accounts/get-user/(?P<username>[\w.+-]+)', views.GetUserAccountView.as_view(), name="get_user_account_api"),

# Just like CourseListView API, but with search
url(r'^search_courses', views.CourseListSearchView.as_view(), name="course_list_search"),

# bulk enrollment API
url(r'^bulk-enrollment/bulk-enroll', views.BulkEnrollView.as_view(), name="bulk_enrollment_api"),

Expand Down
115 changes: 115 additions & 0 deletions lms/djangoapps/appsembler_api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import string
import random

import search
from dateutil import parser

from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
Expand All @@ -11,6 +12,7 @@
from django.http import Http404
from django.db.models import Q
from django.core.validators import validate_email
from rest_framework.generics import ListAPIView

from rest_framework.views import APIView
from rest_framework import status
Expand All @@ -19,10 +21,13 @@
from util.bad_request_rate_limiter import BadRequestRateLimiter
from util.disable_rate_limit import can_disable_rate_limit

from lms.djangoapps.course_api.api import list_courses
from lms.djangoapps.course_api.serializers import CourseSerializer
from openedx.core.djangoapps.user_api.accounts.api import check_account_exists
from openedx.core.lib.api.authentication import (
OAuth2AuthenticationAllowInactiveUser,
)
from openedx.core.lib.api.paginators import NamespacedPageNumberPagination
from openedx.core.lib.api.permissions import (
IsStaffOrOwner, ApiKeyHeaderPermissionIsAuthenticated
)
Expand All @@ -49,6 +54,8 @@
from opaque_keys.edx.keys import CourseKey
from certificates.models import GeneratedCertificate

from openedx.core.lib.api.view_utils import view_auth_classes, DeveloperErrorViewMixin
from .forms import CourseListGetAndSearchForm
from .serializers import BulkEnrollmentSerializer
from .utils import auto_generate_username, send_activation_email

Expand Down Expand Up @@ -583,6 +590,114 @@ def get(self, request):
return Response(user_list, status=200)


@view_auth_classes(is_authenticated=False)
class CourseListSearchView(DeveloperErrorViewMixin, ListAPIView):
"""
**Use Cases**

Request information on all courses visible to the specified user with search.

**Example Requests**

GET /appsembler_api/v0/search_courses?search_term=master

**Response Values**

Body comprises a list of objects as returned by `CourseDetailView`.

**Parameters**
search_term (optional):
Textual search term to filter courses.

username (optional):
The username of the specified user whose visible courses we
want to see. The username is not required only if the API is
requested by an Anonymous user.

org (optional):
If specified, visible `CourseOverview` objects are filtered
such that only those belonging to the organization with the
provided org code (e.g., "HarvardX") are returned.
Case-insensitive.

mobile (optional):
If specified, only visible `CourseOverview` objects that are
designated as mobile_available are returned.


**Returns**

* 200 on success, with a list of course discovery objects as returned
by `CourseDetailView`.
* 400 if an invalid parameter was sent or the username was not provided
for an authenticated request.
* 403 if a user who does not have permission to masquerade as
another user specifies a username other than their own.
* 404 if the specified user does not exist, or the requesting user does
not have permission to view their courses.

Example response:

[
{
"blocks_url": "/api/courses/v1/blocks/?course_id=edX%2Fexample%2F2012_Fall",
"media": {
"course_image": {
"uri": "/c4x/edX/example/asset/just_a_test.jpg",
"name": "Course Image"
}
},
"description": "An example course.",
"end": "2015-09-19T18:00:00Z",
"enrollment_end": "2015-07-15T00:00:00Z",
"enrollment_start": "2015-06-15T00:00:00Z",
"course_id": "edX/example/2012_Fall",
"name": "Example Course",
"number": "example",
"org": "edX",
"start": "2015-07-17T12:00:00Z",
"start_display": "July 17, 2015",
"start_type": "timestamp"
}
]
"""

pagination_class = NamespacedPageNumberPagination
serializer_class = CourseSerializer

# Return all the results, 10K is the maximum allowed value for ElasticSearch.
# We should use 0 after upgrading to 1.1+:
# - https://github.com/elastic/elasticsearch/commit/8b0a863d427b4ebcbcfb1dcd69c996c52e7ae05e
results_size_infinity = 10000

def get_queryset(self):
"""
Return a list of courses visible to the user.
"""
form = CourseListGetAndSearchForm(self.request.query_params, initial={'requesting_user': self.request.user})
if not form.is_valid():
raise ValidationError(form.errors)

courses_db = list_courses(
self.request,
form.cleaned_data['username'],
org=form.cleaned_data['org'],
filter_=form.cleaned_data['filter_'],
)

courses_search = search.api.course_discovery_search(
form.cleaned_data['search_term'],
size=self.results_size_infinity,
)

course_search_ids = {course['data']['id']: True for course in courses_search['results']}

return [
course for course in courses_db
if unicode(course.id) in course_search_ids
]


class GetBatchEnrollmentDataView(APIView):
authentication_classes = OAuth2AuthenticationAllowInactiveUser,
permission_classes = IsStaffOrOwner,
Expand Down
3 changes: 2 additions & 1 deletion lms/envs/devstack_appsembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,5 @@
except ImportError:
pass


# override devstack.py automatic enabling of courseware discovery
FEATURES['ENABLE_COURSE_DISCOVERY'] = ENV_TOKENS['FEATURES'].get('ENABLE_COURSE_DISCOVERY', FEATURES['ENABLE_COURSE_DISCOVERY'])