Commit 49efdabc by Kyle McCormick

Enhance CourseSummaries; add CourseTotals

Updates client to support pagination, filtering, searching, and sorting
now available in the edX Analytics Data API.

Bumps API version used in tests from v0 to v1.
parent fa05cfc1
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)()
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 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