Commit 5b75a0ae by Kyle McCormick Committed by GitHub

Merge pull request #36 from edx/edx/kdmccormick/course-listing-reimp-final

EDUCATOR-854: Update API client to support pagination, filtering, and sorting of course_summaries
parents fa05cfc1 49efdabc
from analyticsclient.constants import http_methods, data_formats
class BaseEndpoint(object):
"""Base class for endpoints that use a client object."""
def __init__(self, client):
"""
Initialize the API client.
Arguments:
client (analyticsclient.client.Client): The client to use to access remote resources.
"""
self.client = client
class PostableCourseIDsEndpoint(BaseEndpoint):
"""Base class for endpoints that pass in course IDs with either GET or POST."""
path = None # Override in subclass
max_num_ids_for_get = 10 # Optionally override in subclass
def do_request(self, course_ids, data, data_format=data_formats.JSON):
"""
Given course IDs, do the appropriate request method (GET or POST).
Arguments:
course_ids (list[str]): A list of course IDs to pass in
data (dict): Arguments for endpoint, sans course IDs
data_format (data_format)
Returns: dict
"""
data_with_ids = data.copy()
if course_ids:
data_with_ids.update(course_ids=course_ids)
method = (
http_methods.POST
if len(course_ids or []) > self.max_num_ids_for_get
else http_methods.GET
)
return self.client.request(method, self.path, data=data_with_ids, data_format=data_format)
......@@ -2,9 +2,10 @@ import logging
import requests
import requests.exceptions
from analyticsclient.constants import data_format as DF
from analyticsclient.constants import http_methods, data_formats
from analyticsclient.course import Course
from analyticsclient.course_totals import CourseTotals
from analyticsclient.course_summaries import CourseSummaries
from analyticsclient.exceptions import ClientError, InvalidRequestError, NotFoundError, TimeoutError
from analyticsclient.module import Module
......@@ -28,15 +29,12 @@ class Client(object):
DATE_FORMAT = '%Y-%m-%d'
DATETIME_FORMAT = DATE_FORMAT + 'T%H%M%S'
METHOD_GET = 'GET'
METHOD_POST = 'POST'
def __init__(self, base_url, auth_token=None, timeout=0.25):
"""
Initialize the client.
Arguments:
base_url (str): URL of the API server (e.g. http://analytics.edx.org/api/v0)
base_url (str): URL of the API server (e.g. http://analytics.edx.org/api/v1)
auth_token (str): Authentication token
timeout (number): Maximum number of seconds during which all requests musts complete
"""
......@@ -46,41 +44,32 @@ class Client(object):
self.status = Status(self)
self.course_summaries = lambda: CourseSummaries(self)
self.course_totals = lambda: CourseTotals(self)
self.programs = lambda: Programs(self)
self.courses = lambda course_id: Course(self, course_id)
self.modules = lambda course_id, module_id: Module(self, course_id, module_id)
def get(self, resource, timeout=None, data_format=DF.JSON):
def get(self, *args, **kwargs):
"""
Retrieve the data for a resource.
Arguments:
resource (str): Path in the form of slash separated strings.
timeout (float): Continue to attempt to retrieve a resource for this many seconds before giving up and
raising an error.
data_format (str): Format in which data should be returned
Equivalent to `request(http_methods.GET, ...)`
Returns: API response data in specified data_format
Raises: ClientError if the resource cannot be retrieved for any reason.
"""
return self._get_or_post(
self.METHOD_GET,
resource,
timeout=timeout,
data_format=data_format
)
return self.request(http_methods.GET, *args, **kwargs)
def post(self, resource, post_data=None, timeout=None, data_format=DF.JSON):
def request(self, method, resource, data=None, timeout=None, data_format=data_formats.JSON):
"""
Retrieve the data for POST request.
Retrieve the from an HTTP request.
Arguments:
method (http_method): HTTP method. Only GET and POST are supported currenly.
resource (str): Path in the form of slash separated strings.
post_data (dict): Dictionary containing POST data.
data (dict): Dictionary containing POST data.
timeout (float): Continue to attempt to retrieve a resource for this many seconds before giving up and
raising an error.
data_format (str): Format in which data should be returned
......@@ -90,14 +79,24 @@ class Client(object):
Raises: ClientError if the resource cannot be retrieved for any reason.
"""
return self._get_or_post(
self.METHOD_POST,
resource,
post_data=post_data,
response = self._request(
method=method,
resource=resource,
data=data,
timeout=timeout,
data_format=data_format
)
if data_format == data_formats.CSV:
return response.text
try:
return response.json()
except ValueError:
message = 'Unable to decode JSON response'
log.exception(message)
raise ClientError(message)
def has_resource(self, resource, timeout=None):
"""
Check if the server responds with a 200 OK status code when the resource is requested.
......@@ -114,37 +113,18 @@ class Client(object):
"""
try:
self._request(self.METHOD_GET, resource, timeout=timeout)
self._request(http_methods.GET, resource, timeout=timeout)
return True
except ClientError:
return False
def _get_or_post(self, method, resource, post_data=None, timeout=None, data_format=DF.JSON):
response = self._request(
method,
resource,
post_data=post_data,
timeout=timeout,
data_format=data_format
)
if data_format == DF.CSV:
return response.text
try:
return response.json()
except ValueError:
message = 'Unable to decode JSON response'
log.exception(message)
raise ClientError(message)
# pylint: disable=no-member
def _request(self, method, resource, post_data=None, timeout=None, data_format=DF.JSON):
def _request(self, method, resource, data=None, timeout=None, data_format=data_formats.JSON):
if timeout is None:
timeout = self.timeout
accept_format = 'application/json'
if data_format == DF.CSV:
if data_format == data_formats.CSV:
accept_format = 'text/csv'
headers = {
......@@ -157,14 +137,17 @@ class Client(object):
try:
uri = '{0}/{1}'.format(self.base_url, resource)
if method == self.METHOD_GET:
response = requests.get(uri, headers=headers, timeout=timeout)
elif method == self.METHOD_POST:
response = requests.post(uri, data=(post_data or {}), headers=headers, timeout=timeout)
if method == http_methods.GET:
params = self._data_to_get_params(data or {})
response = requests.get(uri, params=params, headers=headers, timeout=timeout)
elif method == http_methods.POST:
response = requests.post(uri, data=(data or {}), headers=headers, timeout=timeout)
else:
raise ValueError(
'Invalid \'method\' argument: expected {0} or {1}, got {2}'.format(
self.METHOD_GET, self.METHOD_POST, method
http_methods.GET,
http_methods.POST,
method,
)
)
......@@ -194,3 +177,14 @@ class Client(object):
message = 'Unable to retrieve resource'
log.exception(message)
raise ClientError('{0} "{1}"'.format(message, resource))
@staticmethod
def _data_to_get_params(data):
return {
key: (
','.join(value)
if isinstance(value, list)
else str(value)
)
for key, value in data.iteritems()
}
"""Course activity types."""
ANY = 'any'
ATTEMPTED_PROBLEM = 'attempted_problem'
PLAYED_VIDEO = 'played_video'
POSTED_FORUM = 'posted_forum'
"""Course activity types."""
ANY = u'any'
ATTEMPTED_PROBLEM = u'attempted_problem'
PLAYED_VIDEO = u'played_video'
POSTED_FORUM = u'posted_forum'
"""Course demographics."""
BIRTH_YEAR = 'birth_year'
EDUCATION = 'education'
GENDER = 'gender'
LOCATION = 'location'
"""Course demographics."""
BIRTH_YEAR = u'birth_year'
EDUCATION = u'education'
GENDER = u'gender'
LOCATION = u'location'
NONE = 'none'
OTHER = 'other'
PRIMARY = 'primary'
JUNIOR_SECONDARY = 'junior_secondary'
SECONDARY = 'secondary'
ASSOCIATES = 'associates'
BACHELORS = 'bachelors'
MASTERS = 'masters'
DOCTORATE = 'doctorate'
NONE = u'none'
OTHER = u'other'
PRIMARY = u'primary'
JUNIOR_SECONDARY = u'junior_secondary'
SECONDARY = u'secondary'
ASSOCIATES = u'associates'
BACHELORS = u'bachelors'
MASTERS = u'masters'
DOCTORATE = u'doctorate'
FEMALE = 'female'
MALE = 'male'
OTHER = 'other'
UNKNOWN = 'unknown'
FEMALE = u'female'
MALE = u'male'
OTHER = u'other'
UNKNOWN = u'unknown'
GET = u'GET'
HEAD = u'HEAD'
POST = u'POST'
PUT = u'PUT'
DELETE = u'DELETE'
CONNECT = u'CONNECT'
OPTIONS = u'OPTIONS'
TRACE = u'TRACE'
PATCH = u'PATCH'
import urllib
import warnings
import analyticsclient.constants.activity_type as AT
import analyticsclient.constants.data_format as DF
from analyticsclient.base import PostableCourseIDsEndpoint
from analyticsclient.constants import activity_types, data_formats
from analyticsclient.exceptions import InvalidRequestError
class Course(object):
class Course(PostableCourseIDsEndpoint):
"""Course-related analytics."""
def __init__(self, client, course_id):
......@@ -18,10 +18,10 @@ class Course(object):
course_id (str): String identifying the course (e.g. edX/DemoX/Demo_Course)
"""
self.client = client
super(Course, self).__init__(client)
self.course_id = unicode(course_id)
def enrollment(self, demographic=None, start_date=None, end_date=None, data_format=DF.JSON):
def enrollment(self, demographic=None, start_date=None, end_date=None, data_format=data_formats.JSON):
"""
Get course enrollment data.
......@@ -55,7 +55,7 @@ class Course(object):
return self.client.get(path, data_format=data_format)
def activity(self, activity_type=AT.ANY, start_date=None, end_date=None, data_format=DF.JSON):
def activity(self, activity_type=activity_types.ANY, start_date=None, end_date=None, data_format=data_formats.JSON):
"""
Get the course student activity.
......@@ -82,7 +82,7 @@ class Course(object):
return self.client.get(path, data_format=data_format)
def recent_activity(self, activity_type=AT.ANY, data_format=DF.JSON):
def recent_activity(self, activity_type=activity_types.ANY, data_format=data_formats.JSON):
"""
Get the recent course activity.
......@@ -95,7 +95,7 @@ class Course(object):
path = 'courses/{0}/recent_activity/?activity_type={1}'.format(self.course_id, activity_type)
return self.client.get(path, data_format=data_format)
def problems(self, data_format=DF.JSON):
def problems(self, data_format=data_formats.JSON):
"""
Get the problems for the course.
......@@ -105,7 +105,7 @@ class Course(object):
path = 'courses/{0}/problems/'.format(self.course_id)
return self.client.get(path, data_format=data_format)
def problems_and_tags(self, data_format=DF.JSON):
def problems_and_tags(self, data_format=data_formats.JSON):
"""
Get the problems for the course with assigned tags.
......@@ -115,7 +115,7 @@ class Course(object):
path = 'courses/{0}/problems_and_tags/'.format(self.course_id)
return self.client.get(path, data_format=data_format)
def reports(self, report_name, data_format=DF.JSON):
def reports(self, report_name, data_format=data_formats.JSON):
"""
Get CSV download information for a particular report in the course.
......@@ -125,7 +125,7 @@ class Course(object):
path = 'courses/{0}/reports/{1}/'.format(self.course_id, report_name)
return self.client.get(path, data_format=data_format)
def videos(self, data_format=DF.JSON):
def videos(self, data_format=data_formats.JSON):
"""
Get the videos for the course.
......
import analyticsclient.constants.data_format as DF
from analyticsclient.base import PostableCourseIDsEndpoint
from analyticsclient.constants import data_formats
class CourseSummaries(object):
class CourseSummaries(PostableCourseIDsEndpoint):
"""Course summaries."""
def __init__(self, client):
"""
Initialize the CourseSummaries client.
Arguments:
client (analyticsclient.client.Client): The client to use to access remote resources.
"""
self.client = client
path = 'course_summaries/'
def course_summaries(self, course_ids=None, fields=None, exclude=None, programs=None, data_format=DF.JSON):
def course_summaries(
self,
course_ids=None,
availability=None,
pacing_type=None,
program_ids=None,
text_search=None,
order_by=None,
sort_order=None,
page=None,
page_size=None,
request_all=False,
fields=None,
exclude=None,
data_format=data_formats.JSON,
):
"""
Get list of summaries.
Arguments:
course_ids: Array of course IDs as strings to return. Default is to return all.
fields: Array of fields to return. Default is to return all.
exclude: Array of fields to exclude from response. Default is to not exclude any fields.
programs: If included in the query parameters, will include the programs array in the response.
"""
post_data = {}
for param_name, data in zip(['course_ids', 'fields', 'exclude', 'programs'],
[course_ids, fields, exclude, programs]):
if data:
post_data[param_name] = data
For more detailed parameter and return type descriptions, see the
edX Analytics Data API documentation.
path = 'course_summaries/'
Arguments:
course_ids (list[str]): Course IDs to filter by.
availability (list[str]) Availabilities to filter by.
pacing_type (list[str]): Pacing types to filter by.
program_ids (list[str]): Course IDs of programs to filter by.
text_search (str): Sub-string to search for in course titles and IDs.
order_by (str): Summary field to sort by.
sort_order (str): Order of the sort.
page (int): Page number.
page_size (int): Size of page.
request_all (bool): Whether all summaries should be returned, or just a
single page. Overrides `page` and `page_size`.
fields (list[str]) Fields of course summaries to return in response.
exclude (list[str]) Fields of course summaries to NOT return in response.
data_format (str): Data format for response. Must be data_format.JSON or
data_format.CSV.
return self.client.post(path, post_data=post_data, data_format=data_format)
Returns: dict
"""
raw_data = {
'availability': availability,
'pacing_type': pacing_type,
'program_ids': program_ids,
'text_search': text_search,
'order_by': order_by,
'sort_order': sort_order,
'page': page,
'page_size': page_size,
'fields': fields,
'exclude': exclude,
'all': request_all,
}
data = {
key: value
for key, value in raw_data.iteritems()
if value
}
return self.do_request(course_ids=course_ids, data=data, data_format=data_format)
from analyticsclient.base import PostableCourseIDsEndpoint
from analyticsclient.constants import data_formats
class CourseTotals(PostableCourseIDsEndpoint):
"""Course aggregate data."""
path = 'course_totals/'
def course_totals(self, course_ids=None, data_format=data_formats.JSON):
"""
Get aggregate data about courses.
For more detailed parameter and return type descriptions, see the
edX Analytics Data API documentation.
Arguments:
course_ids (list[str]): Course IDs to filter by.
data_format (str): Data format for response.
Must be data_format.JSON or data_format.CSV.
"""
return self.do_request(course_ids=course_ids, data={}, data_format=data_format)
import analyticsclient.constants.data_format as DF
from analyticsclient.base import BaseEndpoint
from analyticsclient.constants import data_formats
class Module(object):
class Module(BaseEndpoint):
"""Module related analytics data."""
def __init__(self, client, course_id, module_id):
......@@ -13,11 +14,11 @@ class Module(object):
course_id (str): String identifying the course
module_id (str): String identifying the module
"""
self.client = client
super(Module, self).__init__(client)
self.course_id = unicode(course_id)
self.module_id = unicode(module_id)
def answer_distribution(self, data_format=DF.JSON):
def answer_distribution(self, data_format=data_formats.JSON):
"""
Get answer distribution data for a module.
......@@ -28,7 +29,7 @@ class Module(object):
return self.client.get(path, data_format=data_format)
def grade_distribution(self, data_format=DF.JSON):
def grade_distribution(self, data_format=data_formats.JSON):
"""
Get grade distribution data for a module.
......@@ -39,7 +40,7 @@ class Module(object):
return self.client.get(path, data_format=data_format)
def sequential_open_distribution(self, data_format=DF.JSON):
def sequential_open_distribution(self, data_format=data_formats.JSON):
"""
Get open distribution data for a module.
......@@ -50,7 +51,7 @@ class Module(object):
return self.client.get(path, data_format=data_format)
def video_timeline(self, data_format=DF.JSON):
def video_timeline(self, data_format=data_formats.JSON):
"""
Get video segments/timeline for a module.
......
import urllib
import analyticsclient.constants.data_format as DF
from analyticsclient.base import BaseEndpoint
from analyticsclient.constants import data_formats
class Programs(object):
class Programs(BaseEndpoint):
"""Programs client."""
def __init__(self, client):
"""
Initialize the Programs client.
Arguments:
client (analyticsclient.client.Client): The client to use to access remote resources.
"""
self.client = client
def programs(self, program_ids=None, fields=None, exclude=None, data_format=DF.JSON):
def programs(self, program_ids=None, fields=None, exclude=None, data_format=data_formats.JSON, **kwargs):
"""
Get list of programs metadata.
......@@ -28,7 +18,7 @@ class Programs(object):
"""
query_params = {}
for query_arg, data in zip(['program_ids', 'fields', 'exclude'],
[program_ids, fields, exclude]):
[program_ids, fields, exclude]) + kwargs.items():
if data:
query_params[query_arg] = ','.join(data)
......
from analyticsclient.base import BaseEndpoint
from analyticsclient.exceptions import ClientError
class Status(object):
class Status(BaseEndpoint):
"""API server status."""
def __init__(self, client):
"""
Initialize the Status.
Arguments:
client (analyticsclient.client.Client): The client to use to access remote resources.
"""
self.client = client
@property
def alive(self):
"""
......
......@@ -11,7 +11,7 @@ class ClientTestCase(TestCase):
def setUp(self):
"""Configure Client."""
self.api_url = 'http://localhost:9999/api/v0'
self.api_url = 'http://localhost:9999/api/v1'
self.client = Client(self.api_url)
def get_api_url(self, path):
......@@ -28,17 +28,17 @@ class ClientTestCase(TestCase):
@ddt.ddt
class APIListTestCase(object):
"""Base class for API list view tests."""
class APIWithIDsTestCase(object):
"""Base class for tests for API endpoints that take lists of IDs."""
# Override in the subclass:
endpoint = 'list'
endpoint = 'endpoint'
id_field = 'id'
uses_post_method = False
other_params = frozenset()
def setUp(self):
"""Set up the test case."""
super(APIListTestCase, self).setUp()
super(APIWithIDsTestCase, self).setUp()
self.base_uri = self.get_api_url('{}/'.format(self.endpoint))
self.client_class = getattr(self.client, self.endpoint)()
httpretty.enable()
......@@ -49,44 +49,76 @@ class APIListTestCase(object):
def expected_query(self, **kwargs):
"""Pack the query arguments into expected format for http pretty."""
query = {}
for field, data in kwargs.items():
if data is not None:
query[field] = [','.join(data)]
return query
return {
field: (
[','.join(data)] if isinstance(data, list) else [str(data)]
)
for field, data in kwargs.iteritems()
if data
}
@httpretty.activate
def kwarg_test(self, **kwargs):
def verify_query_params(self, **kwargs):
"""Construct URL with given query parameters and check if it is what we expect."""
httpretty.reset()
if self.uses_post_method:
httpretty.register_uri(httpretty.POST, self.base_uri, body='{}')
getattr(self.client_class, self.endpoint)(**kwargs)
self.assertDictEqual(httpretty.last_request().parsed_body or {}, kwargs)
else:
uri_template = '{uri}?'
for key in kwargs:
uri_template += '%s={%s}' % (key, key)
uri = uri_template.format(uri=self.base_uri, **kwargs)
httpretty.register_uri(httpretty.GET, uri, body='{}')
getattr(self.client_class, self.endpoint)(**kwargs)
self.verify_last_querystring_equal(self.expected_query(**kwargs))
def test_all_items_url(self):
"""Endpoint can be called without parameters."""
httpretty.register_uri(
httpretty.POST if self.uses_post_method else httpretty.GET,
self.base_uri, body='{}'
)
getattr(self.client_class, self.endpoint)()
def fill_in_empty_params_with_dummies(self, **kwargs):
"""Fill in non-provided parameters with dummy values so they are tested."""
params = {param: '.' for param in self.other_params}
params.update(kwargs)
return params
@ddt.data(
[],
['edx/demo/course'],
['edx/demo/course', 'another/demo/course']
['edx/demo/course', 'another/demo/course'],
)
def test_courses_ids(self, ids):
"""Endpoint can be called with IDs."""
self.kwarg_test(**{self.id_field: ids})
def test_url_with_params(self, ids):
"""Endpoint can be called with parameters, including IDs."""
params = self.fill_in_empty_params_with_dummies(**{self.id_field: ids})
self.verify_query_params(**params)
def test_url_without_params(self):
"""Endpoint can be called without parameters."""
httpretty.register_uri(httpretty.GET, self.base_uri, body='{}')
getattr(self.client_class, self.endpoint)()
class APIWithPostableIDsTestCase(APIWithIDsTestCase):
"""Base class for tests for API endpoints that can POST a list of course IDs."""
@httpretty.activate
def verify_post_data(self, **kwargs):
"""Construct POST request with parameters and check if it is what we expect."""
httpretty.reset()
httpretty.register_uri(httpretty.POST, self.base_uri, body='{}')
getattr(self.client_class, self.endpoint)(**kwargs)
expected_body = kwargs.copy()
for key, val in expected_body.iteritems():
if not isinstance(val, list):
expected_body[key] = [val]
actual_body = httpretty.last_request().parsed_body
self.assertDictEqual(actual_body or {}, expected_body)
def test_request_with_many_ids(self):
"""Endpoint can be called with a large number of ID parameters."""
params = self.fill_in_empty_params_with_dummies(**{self.id_field: ['id'] * 10000})
self.verify_post_data(**params)
@ddt.ddt
class APIListTestCase(APIWithIDsTestCase):
"""Base class for API list view tests."""
@ddt.data(
['course_id'],
......@@ -94,7 +126,7 @@ class APIListTestCase(object):
)
def test_fields(self, fields):
"""Endpoint can be called with fields."""
self.kwarg_test(fields=fields)
self.verify_query_params(fields=fields)
@ddt.data(
['course_id'],
......@@ -102,7 +134,7 @@ class APIListTestCase(object):
)
def test_exclude(self, exclude):
"""Endpoint can be called with exclude."""
self.kwarg_test(exclude=exclude)
self.verify_query_params(exclude=exclude)
@ddt.data(
(['edx/demo/course'], ['course_id'], ['enrollment_modes']),
......@@ -110,6 +142,9 @@ class APIListTestCase(object):
['created', 'pacing_type'])
)
@ddt.unpack
def test_all_parameters(self, ids, fields, exclude):
"""Endpoint can be called with all parameters."""
self.kwarg_test(**{self.id_field: ids, 'fields': fields, 'exclude': exclude})
def test_all_list_parameters(self, ids, fields, exclude):
"""Endpoint can be called with IDs, fields, and exlude parameters."""
params = self.fill_in_empty_params_with_dummies(
**{self.id_field: ids, 'fields': fields, 'exclude': exclude}
)
self.verify_query_params(**params)
......@@ -5,7 +5,7 @@ import mock
import requests.exceptions
from testfixtures import log_capture
from analyticsclient.constants import data_format
from analyticsclient.constants import data_formats, http_methods
from analyticsclient.client import Client
from analyticsclient.exceptions import ClientError, TimeoutError
from analyticsclient.tests import ClientTestCase
......@@ -49,7 +49,7 @@ class ClientTests(ClientTestCase):
def test_post(self):
data = {'foo': 'bar'}
httpretty.register_uri(httpretty.POST, self.test_url, body=json.dumps(data))
self.assertEquals(self.client.post(self.test_endpoint), data)
self.assertEquals(self.client.request(http_methods.POST, self.test_endpoint), data)
def test_get_invalid_response_body(self):
""" Verify that client raises a ClientError if the response body cannot be properly parsed. """
......@@ -79,25 +79,25 @@ class ClientTests(ClientTestCase):
self.assertRaises(
TimeoutError,
self.client._request,
self.client.METHOD_GET,
http_methods.GET,
self.test_endpoint,
timeout=timeout
)
msg = 'Response from {0} exceeded timeout of {1}s.'.format(self.test_endpoint, self.client.timeout)
lc.check(('analyticsclient.client', 'ERROR', msg))
lc.clear()
mock_get.assert_called_once_with(url, headers=headers, timeout=self.client.timeout)
mock_get.assert_called_once_with(url, headers=headers, timeout=self.client.timeout, params={})
mock_get.reset_mock()
timeout = 10
self.assertRaises(
TimeoutError,
self.client._request,
self.client.METHOD_GET,
http_methods.GET,
self.test_endpoint,
timeout=timeout
)
mock_get.assert_called_once_with(url, headers=headers, timeout=timeout)
mock_get.assert_called_once_with(url, headers=headers, timeout=timeout, params={})
msg = 'Response from {0} exceeded timeout of {1}s.'.format(self.test_endpoint, timeout)
lc.check(('analyticsclient.client', 'ERROR', msg))
......@@ -109,12 +109,12 @@ class ClientTests(ClientTestCase):
self.assertDictEqual(response, {})
httpretty.register_uri(httpretty.GET, self.test_url, body='not-json')
response = self.client.get(self.test_endpoint, data_format=data_format.CSV)
response = self.client.get(self.test_endpoint, data_format=data_formats.CSV)
self.assertEquals(httpretty.last_request().headers['Accept'], 'text/csv')
self.assertEqual(response, 'not-json')
httpretty.register_uri(httpretty.GET, self.test_url, body='{}')
response = self.client.get(self.test_endpoint, data_format=data_format.JSON)
response = self.client.get(self.test_endpoint, data_format=data_formats.JSON)
self.assertEquals(httpretty.last_request().headers['Accept'], 'application/json')
self.assertDictEqual(response, {})
......@@ -122,6 +122,6 @@ class ClientTests(ClientTestCase):
self.assertRaises(
ValueError,
self.client._request,
'PATCH',
http_methods.PATCH,
self.test_endpoint
)
......@@ -3,9 +3,7 @@ import re
import httpretty
from analyticsclient.constants import activity_type as at
from analyticsclient.constants import data_format
from analyticsclient.constants import demographic as demo
from analyticsclient.constants import activity_types, data_formats, demographics
from analyticsclient.exceptions import NotFoundError, InvalidRequestError
from analyticsclient.tests import ClientTestCase
......@@ -82,10 +80,10 @@ class CoursesTests(ClientTestCase):
self.assertDictEqual(body, self.course.recent_activity(activity_type))
def test_recent_activity(self):
self.assertRecentActivityResponseData(self.course, at.ANY)
self.assertRecentActivityResponseData(self.course, at.ATTEMPTED_PROBLEM)
self.assertRecentActivityResponseData(self.course, at.PLAYED_VIDEO)
self.assertRecentActivityResponseData(self.course, at.POSTED_FORUM)
self.assertRecentActivityResponseData(self.course, activity_types.ANY)
self.assertRecentActivityResponseData(self.course, activity_types.ATTEMPTED_PROBLEM)
self.assertRecentActivityResponseData(self.course, activity_types.PLAYED_VIDEO)
self.assertRecentActivityResponseData(self.course, activity_types.POSTED_FORUM)
def test_not_found(self):
""" Course calls should raise a NotFoundError when provided with an invalid course. """
......@@ -96,8 +94,8 @@ class CoursesTests(ClientTestCase):
httpretty.register_uri(httpretty.GET, uri, status=404)
course = self.client.courses(course_id)
self.assertRaises(NotFoundError, course.recent_activity, at.ANY)
self.assertRaises(NotFoundError, course.enrollment, demo.EDUCATION)
self.assertRaises(NotFoundError, course.recent_activity, activity_types.ANY)
self.assertRaises(NotFoundError, course.enrollment, demographics.EDUCATION)
def test_invalid_parameter(self):
""" Course calls should raise a InvalidRequestError when parameters are invalid. """
......@@ -111,17 +109,17 @@ class CoursesTests(ClientTestCase):
def test_enrollment(self):
self.assertCorrectEnrollmentUrl(self.course, None)
self.assertCorrectEnrollmentUrl(self.course, demo.BIRTH_YEAR)
self.assertCorrectEnrollmentUrl(self.course, demo.EDUCATION)
self.assertCorrectEnrollmentUrl(self.course, demo.GENDER)
self.assertCorrectEnrollmentUrl(self.course, demo.LOCATION)
self.assertCorrectEnrollmentUrl(self.course, demographics.BIRTH_YEAR)
self.assertCorrectEnrollmentUrl(self.course, demographics.EDUCATION)
self.assertCorrectEnrollmentUrl(self.course, demographics.GENDER)
self.assertCorrectEnrollmentUrl(self.course, demographics.LOCATION)
def test_activity(self):
self.assertRaises(InvalidRequestError, self.assertCorrectActivityUrl, self.course, None)
self.assertCorrectActivityUrl(self.course, at.ANY)
self.assertCorrectActivityUrl(self.course, at.ATTEMPTED_PROBLEM)
self.assertCorrectActivityUrl(self.course, at.PLAYED_VIDEO)
self.assertCorrectActivityUrl(self.course, at.POSTED_FORUM)
self.assertCorrectActivityUrl(self.course, activity_types.ANY)
self.assertCorrectActivityUrl(self.course, activity_types.ATTEMPTED_PROBLEM)
self.assertCorrectActivityUrl(self.course, activity_types.PLAYED_VIDEO)
self.assertCorrectActivityUrl(self.course, activity_types.POSTED_FORUM)
def test_enrollment_data_format(self):
uri = self.get_api_url('courses/{0}/enrollment/'.format(self.course.course_id))
......@@ -132,7 +130,7 @@ class CoursesTests(ClientTestCase):
self.assertEquals(httpretty.last_request().headers['Accept'], 'application/json')
httpretty.register_uri(httpretty.GET, uri, body='not-json')
self.course.enrollment(data_format=data_format.CSV)
self.course.enrollment(data_format=data_formats.CSV)
self.assertEquals(httpretty.last_request().headers['Accept'], 'text/csv')
@httpretty.activate
......
# pylint: disable=arguments-differ
import ddt
from analyticsclient.tests import ClientTestCase, APIListTestCase
from analyticsclient.tests import (
APIListTestCase,
APIWithPostableIDsTestCase,
ClientTestCase
)
@ddt.ddt
class CourseSummariesTests(APIListTestCase, ClientTestCase):
class CourseSummariesTests(APIListTestCase, APIWithPostableIDsTestCase, ClientTestCase):
endpoint = 'course_summaries'
id_field = 'course_ids'
uses_post_method = True
@ddt.data(
['123'],
['123', '456']
)
def test_programs(self, programs):
"""Course summaries can be called with programs."""
self.kwarg_test(programs=programs)
_LIST_PARAMS = frozenset([
'course_ids',
'availability',
'pacing_type',
'program_ids',
'fields',
'exclude',
])
_STRING_PARAMS = frozenset([
'text_search',
'order_by',
'sort_order',
])
_INT_PARAMS = frozenset([
'page',
'page_size',
])
_ALL_PARAMS = _LIST_PARAMS | _STRING_PARAMS | _INT_PARAMS
other_params = _ALL_PARAMS
# Test URL encoding (note: '+' is not handled right by httpretty, but it works in practice)
_TEST_STRING = 'Aa1_-:/* '
@ddt.data(
(['edx/demo/course'], ['course_id'], ['enrollment_modes'], ['123']),
(['edx/demo/course', 'another/demo/course'], ['course_id', 'enrollment_modes'],
['created', 'pacing_type'], ['123', '456'])
(_LIST_PARAMS, ['a', 'b', 'c']),
(_LIST_PARAMS, [_TEST_STRING]),
(_LIST_PARAMS, []),
(_STRING_PARAMS, _TEST_STRING),
(_STRING_PARAMS, ''),
(_INT_PARAMS, 1),
(_INT_PARAMS, 0),
(frozenset(), None),
)
@ddt.unpack
def test_all_parameters(self, course_ids, fields, exclude, programs):
"""Course summaries can be called with all parameters including programs."""
self.kwarg_test(course_ids=course_ids, fields=fields, exclude=exclude, programs=programs)
def test_all_parameters(self, param_names, param_value):
"""Course summaries can be called with all parameters."""
params = {param_name: None for param_name in self._ALL_PARAMS}
params.update({param_name: param_value for param_name in param_names})
self.verify_query_params(**params)
from analyticsclient.tests import ClientTestCase, APIWithPostableIDsTestCase
class CourseTotalsTests(APIWithPostableIDsTestCase, ClientTestCase):
endpoint = 'course_totals'
id_field = 'course_ids'
other_params = frozenset()
from unittest import TestCase
from analyticsclient.constants import activity_type, demographic, education_level, gender, enrollment_modes
from analyticsclient.constants import activity_types, demographics, education_levels, genders, enrollment_modes
class HelperTests(TestCase):
......@@ -8,32 +8,32 @@ class HelperTests(TestCase):
"""
def test_activity_types(self):
self.assertEqual('any', activity_type.ANY)
self.assertEqual('attempted_problem', activity_type.ATTEMPTED_PROBLEM)
self.assertEqual('played_video', activity_type.PLAYED_VIDEO)
self.assertEqual('posted_forum', activity_type.POSTED_FORUM)
self.assertEqual('any', activity_types.ANY)
self.assertEqual('attempted_problem', activity_types.ATTEMPTED_PROBLEM)
self.assertEqual('played_video', activity_types.PLAYED_VIDEO)
self.assertEqual('posted_forum', activity_types.POSTED_FORUM)
def test_demographics(self):
self.assertEqual('birth_year', demographic.BIRTH_YEAR)
self.assertEqual('education', demographic.EDUCATION)
self.assertEqual('gender', demographic.GENDER)
self.assertEqual('birth_year', demographics.BIRTH_YEAR)
self.assertEqual('education', demographics.EDUCATION)
self.assertEqual('gender', demographics.GENDER)
def test_education_levels(self):
self.assertEqual('none', education_level.NONE)
self.assertEqual('other', education_level.OTHER)
self.assertEqual('primary', education_level.PRIMARY)
self.assertEqual('junior_secondary', education_level.JUNIOR_SECONDARY)
self.assertEqual('secondary', education_level.SECONDARY)
self.assertEqual('associates', education_level.ASSOCIATES)
self.assertEqual('bachelors', education_level.BACHELORS)
self.assertEqual('masters', education_level.MASTERS)
self.assertEqual('doctorate', education_level.DOCTORATE)
self.assertEqual('none', education_levels.NONE)
self.assertEqual('other', education_levels.OTHER)
self.assertEqual('primary', education_levels.PRIMARY)
self.assertEqual('junior_secondary', education_levels.JUNIOR_SECONDARY)
self.assertEqual('secondary', education_levels.SECONDARY)
self.assertEqual('associates', education_levels.ASSOCIATES)
self.assertEqual('bachelors', education_levels.BACHELORS)
self.assertEqual('masters', education_levels.MASTERS)
self.assertEqual('doctorate', education_levels.DOCTORATE)
def test_genders(self):
self.assertEqual('female', gender.FEMALE)
self.assertEqual('male', gender.MALE)
self.assertEqual('other', gender.OTHER)
self.assertEqual('unknown', gender.UNKNOWN)
self.assertEqual('female', genders.FEMALE)
self.assertEqual('male', genders.MALE)
self.assertEqual('other', genders.OTHER)
self.assertEqual('unknown', genders.UNKNOWN)
def test_enrollment_modes(self):
self.assertEqual('audit', enrollment_modes.AUDIT)
......
......@@ -8,3 +8,4 @@ class ProgramsTests(APIListTestCase, ClientTestCase):
endpoint = 'programs'
id_field = 'program_ids'
other_params = frozenset()
......@@ -2,7 +2,7 @@ from distutils.core import setup
setup(
name='edx-analytics-data-api-client',
version='0.12.0',
version='0.13.0',
packages=['analyticsclient', 'analyticsclient.constants'],
url='https://github.com/edx/edx-analytics-data-api-client',
description='Client used to access edX analytics data warehouse',
......
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