Commit 7e5476c8 by Kyle McCormick

Enhance course_summaries/ endpoint; add course_totals/

Add filtering, sorting, searching, pagination, and caching to
course_summaries.

Introduce new endpoint course_totals.

Bump API version from v0 to v1.

EDUCATOR-852
parent 859d3768
......@@ -5,4 +5,4 @@ PROFESSIONAL = u'professional'
PROFESSIONAL_NO_ID = u'no-id-professional'
VERIFIED = u'verified'
ALL = [AUDIT, CREDIT, HONOR, PROFESSIONAL, PROFESSIONAL_NO_ID, VERIFIED]
ALL = frozenset([AUDIT, CREDIT, HONOR, PROFESSIONAL, PROFESSIONAL_NO_ID, VERIFIED])
......@@ -14,7 +14,7 @@
"problem_display_name": "Earth Science Question",
"question_text": "Enter your answer:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 1
},
{
......@@ -32,7 +32,7 @@
"problem_display_name": "Earth Science Question",
"question_text": "Enter your answer:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 2
},
{
......@@ -50,7 +50,7 @@
"problem_display_name": "Earth Science Question",
"question_text": "Enter your answer:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 3
},
{
......@@ -68,7 +68,7 @@
"problem_display_name": "Earth Science Question",
"question_text": "Enter your answer:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 4
},
{
......@@ -86,7 +86,7 @@
"problem_display_name": null,
"question_text": null
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 5
},
{
......@@ -104,7 +104,7 @@
"problem_display_name": null,
"question_text": null
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 6
},
{
......@@ -122,7 +122,7 @@
"problem_display_name": null,
"question_text": null
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 7
},
{
......@@ -140,7 +140,7 @@
"problem_display_name": "Example problem",
"question_text": "Enter an answer:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 8
},
{
......@@ -158,7 +158,7 @@
"problem_display_name": "Example problem",
"question_text": "Enter an answer:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 9
},
{
......@@ -176,7 +176,7 @@
"problem_display_name": "Example problem",
"question_text": "Enter an answer:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 10
},
{
......@@ -194,7 +194,7 @@
"problem_display_name": "Example problem",
"question_text": "Randomized answer"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 11
},
{
......@@ -212,7 +212,7 @@
"problem_display_name": "Example problem",
"question_text": "Randomized answer"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 12
},
......@@ -231,7 +231,7 @@
"problem_display_name": "Example problem",
"question_text": "Select from the choices below:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 13
},
{
......@@ -249,7 +249,7 @@
"problem_display_name": "Example problem",
"question_text": "Select from the choices below:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 14
},
{
......@@ -267,7 +267,7 @@
"problem_display_name": "Example problem",
"question_text": "Select from the choices below:"
},
"model": "v0.problemfirstlastresponseanswerdistribution",
"model": "v1.problemfirstlastresponseanswerdistribution",
"pk": 15
}
......
......@@ -12,7 +12,7 @@ from django.core.management.base import BaseCommand
from django.utils import timezone
from analytics_data_api.constants import engagement_events
from analytics_data_api.v0 import models
from analytics_data_api.v1 import models
from analyticsdataserver.clients import CourseBlocksApiClient
logging.basicConfig(level=logging.INFO)
......
......@@ -2,7 +2,7 @@ from django.conf.urls import url, include
from rest_framework.urlpatterns import format_suffix_patterns
urlpatterns = [
url(r'^v0/', include('analytics_data_api.v0.urls', 'v0')),
url(r'^v1/', include('analytics_data_api.v1.urls', 'v1')),
]
urlpatterns = format_suffix_patterns(urlpatterns)
......@@ -10,7 +10,7 @@ from rest_framework.authtoken.models import Token
from opaque_keys.edx.locator import CourseKey
from opaque_keys import InvalidKeyError
from analytics_data_api.v0.exceptions import (
from analytics_data_api.v1.exceptions import (
ReportFileNotFoundError,
CannotCreateReportDownloadLinkError
)
......@@ -230,3 +230,25 @@ def get_expiration_date(seconds):
Determine when a given link will expire, based on a given lifetime
"""
return datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds)
class classproperty(object):
"""
A decorator for declaring a class-level property.
Conceptually ike combining @classmethod and @property, however that
doesn't work in practice, so we have to define our own decorator here.
"""
def __init__(self, getter):
self.getter = getter
def __get__(self, instance, owner):
return self.getter(owner)
def join_dicts(*dicts):
joined = {}
for d in dicts:
joined.update(d)
return joined
default_app_config = 'analytics_data_api.v0.apps.ApiAppConfig'
import datetime
import ddt
from django_dynamic_fixture import G
import pytz
from django.conf import settings
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.tests.views import CourseSamples, VerifyCourseIdMixin, APIListViewTestMixin
from analyticsdataserver.tests import TestCaseWithAuthentication
@ddt.ddt
class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication, APIListViewTestMixin):
model = models.CourseMetaSummaryEnrollment
model_id = 'course_id'
ids_param = 'course_ids'
serializer = serializers.CourseMetaSummaryEnrollmentSerializer
expected_summaries = []
list_name = 'course_summaries'
default_ids = CourseSamples.course_ids
always_exclude = ['created', 'programs']
test_post_method = True
def setUp(self):
super(CourseSummariesViewTests, self).setUp()
self.now = datetime.datetime.utcnow()
self.maxDiff = None
def tearDown(self):
self.model.objects.all().delete()
def create_model(self, model_id, **kwargs):
for mode in kwargs['modes']:
G(self.model, course_id=model_id, catalog_course_title='Title', catalog_course='Catalog',
start_time=datetime.datetime(2016, 10, 11, tzinfo=pytz.utc),
end_time=datetime.datetime(2016, 12, 18, tzinfo=pytz.utc),
pacing_type='instructor', availability=kwargs['availability'], enrollment_mode=mode,
count=5, cumulative_count=10, count_change_7_days=1, passing_users=1, create=self.now,)
if 'programs' in kwargs and kwargs['programs']:
# Create a link from this course to a program
G(models.CourseProgramMetadata, course_id=model_id, program_id=CourseSamples.program_ids[0],
program_type='Demo', program_title='Test')
def generate_data(self, ids=None, modes=None, availability='Current', **kwargs):
"""Generate course summary data"""
if modes is None:
modes = enrollment_modes.ALL
super(CourseSummariesViewTests, self).generate_data(ids=ids, modes=modes, availability=availability, **kwargs)
def expected_result(self, item_id, modes=None, availability='Current', programs=False): # pylint: disable=arguments-differ
"""Expected summary information for a course and modes to populate with data."""
summary = super(CourseSummariesViewTests, self).expected_result(item_id)
if modes is None:
modes = enrollment_modes.ALL
num_modes = len(modes)
count_factor = 5
cumulative_count_factor = 10
count_change_factor = 1
summary.update([
('catalog_course_title', 'Title'),
('catalog_course', 'Catalog'),
('start_date', datetime.datetime(2016, 10, 11, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT)),
('end_date', datetime.datetime(2016, 12, 18, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT)),
('pacing_type', 'instructor'),
('availability', availability),
('count', count_factor * num_modes),
('cumulative_count', cumulative_count_factor * num_modes),
('count_change_7_days', count_change_factor * num_modes),
('passing_users', count_change_factor * num_modes),
('enrollment_modes', {}),
])
summary['enrollment_modes'].update({
mode: {
'count': count_factor,
'cumulative_count': cumulative_count_factor,
'count_change_7_days': count_change_factor,
'passing_users': count_change_factor,
} for mode in modes
})
summary['enrollment_modes'].update({
mode: {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0,
'passing_users': 0,
} for mode in set(enrollment_modes.ALL) - set(modes)
})
no_prof = summary['enrollment_modes'].pop(enrollment_modes.PROFESSIONAL_NO_ID)
prof = summary['enrollment_modes'].get(enrollment_modes.PROFESSIONAL)
prof.update({
'count': prof['count'] + no_prof['count'],
'cumulative_count': prof['cumulative_count'] + no_prof['cumulative_count'],
'count_change_7_days': prof['count_change_7_days'] + no_prof['count_change_7_days'],
'passing_users': prof['passing_users'] + no_prof['passing_users'],
})
if programs:
summary['programs'] = [CourseSamples.program_ids[0]]
return summary
def all_expected_results(self, ids=None, modes=None, availability='Current', programs=False): # pylint: disable=arguments-differ
if modes is None:
modes = enrollment_modes.ALL
return super(CourseSummariesViewTests, self).all_expected_results(ids=ids, modes=modes,
availability=availability,
programs=programs)
@ddt.data(
None,
CourseSamples.course_ids,
['not/real/course'].extend(CourseSamples.course_ids),
)
def test_all_courses(self, course_ids):
self._test_all_items(course_ids)
@ddt.data(*CourseSamples.course_ids)
def test_one_course(self, course_id):
self._test_one_item(course_id)
@ddt.data(
['availability'],
['enrollment_mode', 'course_id'],
)
def test_fields(self, fields):
self._test_fields(fields)
@ddt.data(
[enrollment_modes.VERIFIED],
[enrollment_modes.HONOR, enrollment_modes.PROFESSIONAL],
)
def test_empty_modes(self, modes):
self.generate_data(modes=modes)
response = self.validated_request(exclude=self.always_exclude)
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(modes=modes))
@ddt.data(
['malformed-course-id'],
[CourseSamples.course_ids[0], 'malformed-course-id'],
)
def test_bad_course_id(self, course_ids):
response = self.validated_request(ids=course_ids)
self.verify_bad_course_id(response)
def test_collapse_upcoming(self):
self.generate_data(availability='Starting Soon')
self.generate_data(ids=['foo/bar/baz'], availability='Upcoming')
response = self.validated_request(exclude=self.always_exclude)
self.assertEquals(response.status_code, 200)
expected_summaries = self.all_expected_results(availability='Upcoming')
expected_summaries.extend(self.all_expected_results(ids=['foo/bar/baz'],
availability='Upcoming'))
self.assertItemsEqual(response.data, expected_summaries)
def test_programs(self):
self.generate_data(programs=True)
response = self.validated_request(exclude=self.always_exclude[:1], programs=['True'])
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(programs=True))
@ddt.data('passing_users', )
def test_exclude(self, field):
self.generate_data()
response = self.validated_request(exclude=[field])
self.assertEquals(response.status_code, 200)
self.assertEquals(str(response.data).count(field), 0)
from itertools import groupby
from django.db import models
from django.db.models import Q
from django.utils import timezone
from rest_framework import generics, serializers
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.v0.exceptions import CourseNotSpecifiedError
from analytics_data_api.v0.views.utils import (
raise_404_if_none,
split_query_argument,
validate_course_id
)
class CourseViewMixin(object):
"""
Captures the course_id from the url and validates it.
"""
course_id = None
def get(self, request, *args, **kwargs):
self.course_id = self.kwargs.get('course_id', request.query_params.get('course_id', None))
if not self.course_id:
raise CourseNotSpecifiedError()
validate_course_id(self.course_id)
return super(CourseViewMixin, self).get(request, *args, **kwargs)
class PaginatedHeadersMixin(object):
"""
If the response is paginated, then augment it with this response header:
* Link: list of next and previous pagination URLs, e.g.
<next_url>; rel="next", <previous_url>; rel="prev"
Format follows the github API convention:
https://developer.github.com/guides/traversing-with-pagination/
Useful with PaginatedCsvRenderer, so that previous/next links aren't lost when returning CSV data.
"""
# TODO: When we upgrade to Django REST API v3.1, define a custom DEFAULT_PAGINATION_CLASS
# instead of using this mechanism:
# http://www.django-rest-framework.org/api-guide/pagination/#header-based-pagination
def get(self, request, *args, **kwargs):
"""
Stores pagination links in a response header.
"""
response = super(PaginatedHeadersMixin, self).get(request, args, kwargs)
link = self.get_paginated_links(response.data)
if link:
response['Link'] = link
return response
@staticmethod
def get_paginated_links(data):
"""
Returns the links string.
"""
# Un-paginated data is returned as a list, not a dict.
next_url = None
prev_url = None
if isinstance(data, dict):
next_url = data.get('next')
prev_url = data.get('previous')
if next_url is not None and prev_url is not None:
link = '<{next_url}>; rel="next", <{prev_url}>; rel="prev"'
elif next_url is not None:
link = '<{next_url}>; rel="next"'
elif prev_url is not None:
link = '<{prev_url}>; rel="prev"'
else:
link = ''
return link.format(next_url=next_url, prev_url=prev_url)
class CsvViewMixin(object):
"""
Augments a text/csv response with this header:
* Content-Disposition: allows the client to download the response as a file attachment.
"""
# Default filename slug for CSV download files
filename_slug = 'report'
def get_csv_filename(self):
"""
Returns the filename for the CSV download.
"""
course_key = CourseKey.from_string(self.course_id)
course_id = u'-'.join([course_key.org, course_key.course, course_key.run])
now = timezone.now().replace(microsecond=0)
return u'{0}--{1}--{2}.csv'.format(course_id, now.isoformat(), self.filename_slug)
def finalize_response(self, request, response, *args, **kwargs):
"""
Append Content-Disposition header to CSV requests.
"""
if request.META.get('HTTP_ACCEPT') == u'text/csv':
response['Content-Disposition'] = u'attachment; filename={}'.format(self.get_csv_filename())
return super(CsvViewMixin, self).finalize_response(request, response, *args, **kwargs)
class APIListView(generics.ListAPIView):
"""
An abstract view to store common code for views that return a list of data.
**Example Requests**
GET /api/v0/some_endpoint/
Returns full list of serialized models with all default fields.
GET /api/v0/some_endpoint/?ids={id_1},{id_2}
Returns list of serialized models with IDs that match an ID in the given
`ids` query parameter with all default fields.
GET /api/v0/some_endpoint/?ids={id_1},{id_2}&fields={some_field_1},{some_field_2}
Returns list of serialized models with IDs that match an ID in the given
`ids` query parameter with only the fields in the given `fields` query parameter.
GET /api/v0/some_endpoint/?ids={id_1},{id_2}&exclude={some_field_1},{some_field_2}
Returns list of serialized models with IDs that match an ID in the given
`ids` query parameter with all fields except those in the given `exclude` query
parameter.
POST /api/v0/some_endpoint/
{
"ids": [
"{id_1}",
"{id_2}",
...
"{id_200}"
],
"fields": [
"{some_field_1}",
"{some_field_2}"
]
}
**Response Values**
Since this is an abstract class, this view just returns an empty list.
**Parameters**
This view supports filtering the results by a given list of IDs. It also supports
explicitly specifying the fields to include in each result with `fields` as well of
the fields to exclude with `exclude`.
For GET requests, these parameters are passed in the query string.
For POST requests, these parameters are passed as a JSON dict in the request body.
ids -- The comma-separated list of identifiers for which results are filtered to.
For example, 'edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016'. Default is to
return all courses.
fields -- The comma-separated fields to return in the response.
For example, 'course_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
For example, 'course_id,created'. Default is to not exclude any fields.
**Notes**
* GET is usable when the number of IDs is relatively low
* POST is required when the number of course IDs would cause the URL to be too long.
* POST functions the same as GET here. It does not modify any state.
"""
ids = None
fields = None
exclude = None
always_exclude = []
model_id_field = 'id'
ids_param = 'ids'
def get_serializer(self, *args, **kwargs):
kwargs.update({
'context': self.get_serializer_context(),
'fields': self.fields,
'exclude': self.exclude
})
return self.get_serializer_class()(*args, **kwargs)
def get(self, request, *args, **kwargs):
query_params = self.request.query_params
self.fields = split_query_argument(query_params.get('fields'))
exclude = split_query_argument(query_params.get('exclude'))
self.exclude = self.always_exclude + (exclude if exclude else [])
self.ids = split_query_argument(query_params.get(self.ids_param))
self.verify_ids()
return super(APIListView, self).get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
# self.request.data is a QueryDict. For keys with singleton lists as values,
# QueryDicts return the singleton element of the list instead of the list itself,
# which is undesirable. So, we convert to a normal dict.
request_data_dict = dict(request.data)
self.fields = request_data_dict.get('fields')
exclude = request_data_dict.get('exclude')
self.exclude = self.always_exclude + (exclude if exclude else [])
self.ids = request_data_dict.get(self.ids_param)
self.verify_ids()
return super(APIListView, self).get(request, *args, **kwargs)
def verify_ids(self):
"""
Optionally raise an exception if any of the IDs set as self.ids are invalid.
By default, no verification is done.
Subclasses can override this if they wish to perform verification.
"""
pass
def base_field_dict(self, item_id):
"""Default result with fields pre-populated to default values."""
field_dict = {
self.model_id_field: item_id,
}
return field_dict
def update_field_dict_from_model(self, model, base_field_dict=None, field_list=None):
field_list = (field_list if field_list else
[f.name for f in self.model._meta.get_fields()]) # pylint: disable=protected-access
field_dict = base_field_dict if base_field_dict else {}
field_dict.update({field: getattr(model, field) for field in field_list})
return field_dict
def postprocess_field_dict(self, field_dict):
"""Applies some business logic to final result without access to any data from the original model."""
return field_dict
def group_by_id(self, queryset):
"""Return results aggregated by a distinct ID."""
aggregate_field_dict = []
for item_id, model_group in groupby(queryset, lambda x: (getattr(x, self.model_id_field))):
field_dict = self.base_field_dict(item_id)
for model in model_group:
field_dict = self.update_field_dict_from_model(model, base_field_dict=field_dict)
field_dict = self.postprocess_field_dict(field_dict)
aggregate_field_dict.append(field_dict)
return aggregate_field_dict
def get_query(self):
return reduce(lambda q, item_id: q | Q(id=item_id), self.ids, Q())
@raise_404_if_none
def get_queryset(self):
if self.ids:
queryset = self.model.objects.filter(self.get_query())
else:
queryset = self.model.objects.all()
field_dict = self.group_by_id(queryset)
# Django-rest-framework will serialize this dictionary to a JSON response
return field_dict
from django.db.models import Q
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.views import APIListView
from analytics_data_api.v0.views.utils import (
split_query_argument,
validate_course_id,
)
class CourseSummariesView(APIListView):
"""
Returns summary information for courses.
**Example Requests**
GET /api/v0/course_summaries/?course_ids={course_id_1},{course_id_2}
POST /api/v0/course_summaries/
{
"course_ids": [
"{course_id_1}",
"{course_id_2}",
...
"{course_id_200}"
]
}
**Response Values**
Returns enrollment counts and other metadata for each course:
* course_id: The ID of the course for which data is returned.
* catalog_course_title: The name of the course.
* catalog_course: Course identifier without run.
* start_date: The date and time that the course begins
* end_date: The date and time that the course ends
* pacing_type: The type of pacing for this course
* availability: Availability status of the course
* count: The total count of currently enrolled learners across modes.
* cumulative_count: The total cumulative total of all users ever enrolled across modes.
* count_change_7_days: Total difference in enrollment counts over the past 7 days across modes.
* enrollment_modes: For each enrollment mode, the count, cumulative_count, and count_change_7_days.
* created: The date the counts were computed.
* programs: List of program IDs that this course is a part of.
**Parameters**
Results can be filed to the course IDs specified or limited to the fields.
For GET requests, these parameters are passed in the query string.
For POST requests, these parameters are passed as a JSON dict in the request body.
course_ids -- The comma-separated course identifiers for which summaries are requested.
For example, 'edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016'. Default is to
return all courses.
fields -- The comma-separated fields to return in the response.
For example, 'course_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
For example, 'course_id,created'. Default is to exclude the programs array.
programs -- If included in the query parameters, will find each courses' program IDs
and include them in the response.
**Notes**
* GET is usable when the number of course IDs is relatively low
* POST is required when the number of course IDs would cause the URL to be too long.
* POST functions the same as GET for this endpoint. It does not modify any state.
"""
serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer
programs_serializer_class = serializers.CourseProgramMetadataSerializer
model = models.CourseMetaSummaryEnrollment
model_id_field = 'course_id'
ids_param = 'course_ids'
programs_model = models.CourseProgramMetadata
count_fields = ('count', 'cumulative_count', 'count_change_7_days',
'passing_users') # are initialized to 0 by default
summary_meta_fields = ['catalog_course_title', 'catalog_course', 'start_time', 'end_time',
'pacing_type', 'availability'] # fields to extract from summary model
def get(self, request, *args, **kwargs):
query_params = self.request.query_params
programs = split_query_argument(query_params.get('programs'))
if not programs:
self.always_exclude = self.always_exclude + ['programs']
response = super(CourseSummariesView, self).get(request, *args, **kwargs)
return response
def post(self, request, *args, **kwargs):
# self.request.data is a QueryDict. For keys with singleton lists as values,
# QueryDicts return the singleton element of the list instead of the list itself,
# which is undesirable. So, we convert to a normal dict.
request_data_dict = dict(self.request.data)
programs = request_data_dict.get('programs')
if not programs:
self.always_exclude = self.always_exclude + ['programs']
response = super(CourseSummariesView, self).post(request, *args, **kwargs)
return response
def verify_ids(self):
"""
Raise an exception if any of the course IDs set as self.ids are invalid.
Overrides APIListView.verify_ids.
"""
if self.ids is not None:
for item_id in self.ids:
validate_course_id(item_id)
def base_field_dict(self, course_id):
"""Default summary with fields populated to default levels."""
summary = super(CourseSummariesView, self).base_field_dict(course_id)
summary.update({
'created': None,
'enrollment_modes': {},
})
summary.update({field: 0 for field in self.count_fields})
summary['enrollment_modes'].update({
mode: {
count_field: 0 for count_field in self.count_fields
} for mode in enrollment_modes.ALL
})
return summary
def update_field_dict_from_model(self, model, base_field_dict=None, field_list=None):
field_dict = super(CourseSummariesView, self).update_field_dict_from_model(model,
base_field_dict=base_field_dict,
field_list=self.summary_meta_fields)
field_dict['enrollment_modes'].update({
model.enrollment_mode: {field: getattr(model, field) for field in self.count_fields}
})
# treat the most recent as the authoritative created date -- should be all the same
field_dict['created'] = max(model.created, field_dict['created']) if field_dict['created'] else model.created
# update totals for all counts
field_dict.update({field: field_dict[field] + getattr(model, field) for field in self.count_fields})
return field_dict
def postprocess_field_dict(self, field_dict):
# Merge professional with non verified professional
modes = field_dict['enrollment_modes']
prof_no_id_mode = modes.pop(enrollment_modes.PROFESSIONAL_NO_ID, {})
prof_mode = modes[enrollment_modes.PROFESSIONAL]
for count_key in self.count_fields:
prof_mode[count_key] = prof_mode.get(count_key, 0) + prof_no_id_mode.pop(count_key, 0)
# AN-8236 replace "Starting Soon" to "Upcoming" availability to collapse the two into one value
if field_dict['availability'] == 'Starting Soon':
field_dict['availability'] = 'Upcoming'
if self.exclude == [] or (self.exclude and 'programs' not in self.exclude):
# don't do expensive looping for programs if we are just going to throw it away
field_dict = self.add_programs(field_dict)
for field in self.exclude:
for mode in field_dict['enrollment_modes']:
_ = field_dict['enrollment_modes'][mode].pop(field, None)
return field_dict
def add_programs(self, field_dict):
"""Query for programs attached to a course and include them (just the IDs) in the course summary dict"""
field_dict['programs'] = []
queryset = self.programs_model.objects.filter(course_id=field_dict['course_id'])
for program in queryset:
program = self.programs_serializer_class(program.__dict__)
field_dict['programs'].append(program.data['program_id'])
return field_dict
def get_query(self):
return reduce(lambda q, item_id: q | Q(course_id=item_id), self.ids, Q())
from django.db.models import Q
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.views import APIListView
class ProgramsView(APIListView):
"""
Returns metadata information for programs.
**Example Request**
GET /api/v0/course_programs/?program_ids={program_id},{program_id}
**Response Values**
Returns metadata for every program:
* program_id: The ID of the program for which data is returned.
* program_type: The type of the program
* program_title: The title of the program
* created: The date the metadata was computed.
**Parameters**
Results can be filtered to the program IDs specified or limited to the fields.
program_ids -- The comma-separated program identifiers for which metadata is requested.
Default is to return all programs.
fields -- The comma-separated fields to return in the response.
For example, 'program_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
For example, 'program_id,created'. Default is to not exclude any fields.
"""
serializer_class = serializers.CourseProgramMetadataSerializer
model = models.CourseProgramMetadata
model_id_field = 'program_id'
ids_param = 'program_ids'
program_meta_fields = ['program_type', 'program_title']
def base_field_dict(self, program_id):
"""Default program with id, empty metadata, and empty courses array."""
program = super(ProgramsView, self).base_field_dict(program_id)
program.update({
'program_type': '',
'program_title': '',
'created': None,
'course_ids': [],
})
return program
def update_field_dict_from_model(self, model, base_field_dict=None, field_list=None):
field_dict = super(ProgramsView, self).update_field_dict_from_model(model, base_field_dict=base_field_dict,
field_list=self.program_meta_fields)
field_dict['course_ids'].append(model.course_id)
# treat the most recent as the authoritative created date -- should be all the same
field_dict['created'] = max(model.created, field_dict['created']) if field_dict['created'] else model.created
return field_dict
def get_query(self):
return reduce(lambda q, item_id: q | Q(program_id=item_id), self.ids, Q())
default_app_config = 'analytics_data_api.v1.apps.ApiAppConfig'
......@@ -5,7 +5,7 @@ from elasticsearch_dsl import connections
class ApiAppConfig(AppConfig):
name = 'analytics_data_api.v0'
name = 'analytics_data_api.v1'
def ready(self):
from analytics_data_api.utils import load_fully_qualified_definition
......
......@@ -2,7 +2,7 @@ import abc
from django.http.response import JsonResponse
from rest_framework import status
from analytics_data_api.v0.exceptions import (
from analytics_data_api.v1.exceptions import (
CourseKeyMalformedError,
CourseNotSpecifiedError,
LearnerEngagementTimelineNotFoundError,
......
......@@ -68,12 +68,12 @@ class CourseEnrollmentModeDaily(BaseCourseEnrollment):
class CourseMetaSummaryEnrollment(BaseCourseModel):
catalog_course_title = models.CharField(db_index=True, max_length=255)
catalog_course_title = models.CharField(db_index=True, null=True, max_length=255)
catalog_course = models.CharField(db_index=True, max_length=255)
start_time = models.DateTimeField()
end_time = models.DateTimeField()
pacing_type = models.CharField(db_index=True, max_length=255)
availability = models.CharField(db_index=True, max_length=255)
start_time = models.DateTimeField(null=True)
end_time = models.DateTimeField(null=True)
pacing_type = models.CharField(db_index=True, max_length=255, null=True)
availability = models.CharField(db_index=True, max_length=255, null=True)
enrollment_mode = models.CharField(max_length=255)
count = models.IntegerField(null=False)
cumulative_count = models.IntegerField(null=False)
......
......@@ -8,7 +8,7 @@ from analytics_data_api.constants import (
engagement_events,
enrollment_modes,
)
from analytics_data_api.v0 import models
from analytics_data_api.v1 import models
# Below are the enrollment modes supported by this API.
......@@ -561,6 +561,7 @@ class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, Dyn
count = serializers.IntegerField(default=0)
cumulative_count = serializers.IntegerField(default=0)
count_change_7_days = serializers.IntegerField(default=0)
verified_enrollment = serializers.IntegerField(default=0)
passing_users = serializers.IntegerField(default=0)
enrollment_modes = serializers.SerializerMethodField()
programs = serializers.SerializerMethodField()
......@@ -569,7 +570,7 @@ class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, Dyn
return obj.get('enrollment_modes', None)
def get_programs(self, obj):
return obj.get('programs', None)
return list(obj.get('programs', set()))
class Meta(object):
model = models.CourseMetaSummaryEnrollment
......@@ -594,3 +595,13 @@ class CourseProgramMetadataSerializer(ModelSerializerWithCreatedField, DynamicFi
# excluding course-related fields because the serialized output will be embedded in a course object
# with those fields already defined
exclude = ('id', 'created', 'course_id')
class CourseTotalsSerializer(serializers.Serializer):
"""
Serializer for course totals data.
"""
count = serializers.IntegerField()
cumulative_count = serializers.IntegerField()
count_change_7_days = serializers.IntegerField()
verified_enrollment = serializers.IntegerField()
......@@ -4,7 +4,7 @@ from django.test import TestCase
from elasticsearch.exceptions import ElasticsearchException
from mock import patch
from analytics_data_api.v0.connections import BotoHttpConnection, ESConnection
from analytics_data_api.v1.connections import BotoHttpConnection, ESConnection
class ESConnectionTests(TestCase):
......@@ -47,7 +47,7 @@ class ESConnectionTests(TestCase):
class BotoHttpConnectionTests(TestCase):
@patch('analytics_data_api.v0.connections.ESConnection.make_request')
@patch('analytics_data_api.v1.connections.ESConnection.make_request')
def test_perform_request_success(self, mock_response):
mock_response.return_value.status = 200
connection = BotoHttpConnection(aws_access_key_id='access_key', aws_secret_access_key='secret')
......@@ -56,7 +56,7 @@ class BotoHttpConnectionTests(TestCase):
self.assertEqual(status, 200)
self.assertGreater(mock_logger.call_count, 0)
@patch('analytics_data_api.v0.connections.ESConnection.make_request')
@patch('analytics_data_api.v1.connections.ESConnection.make_request')
def test_perform_request_error(self, mock_response):
mock_response.return_value.status = 500
connection = BotoHttpConnection(aws_access_key_id='access_key', aws_secret_access_key='secret')
......
from django.test import TestCase
from django_dynamic_fixture import G
from analytics_data_api.v0 import models
from analytics_data_api.v1 import models
from analytics_data_api.constants.country import UNKNOWN_COUNTRY, get_country
......
......@@ -2,7 +2,7 @@ from datetime import date
from django.test import TestCase
from django_dynamic_fixture import G
from analytics_data_api.v0 import models as api_models, serializers as api_serializers
from analytics_data_api.v1 import models as api_models, serializers as api_serializers
class TestSerializer(api_serializers.CourseEnrollmentDailySerializer, api_serializers.DynamicFieldsModelSerializer):
......
......@@ -3,7 +3,7 @@ from django.test import TestCase
class UrlRedirectTests(TestCase):
api_root_path = '/api/v0/'
api_root_path = '/api/v1/'
def assertRedirectsToRootPath(self, path, **kwargs):
assert_kwargs = {'status_code': 302}
......
......@@ -7,7 +7,7 @@ from urllib import urlencode
from django_dynamic_fixture import G
from rest_framework import status
from analytics_data_api.v0.tests.utils import flatten
from analytics_data_api.v1.tests.utils import flatten
class CourseSamples(object):
......@@ -18,6 +18,8 @@ class CourseSamples(object):
'ccx-v1:edx+1.005x-CCX+rerun+ccx@15'
]
four_course_ids = course_ids[:3] + ['course-v1:A+B+C']
program_ids = [
'482dee71-e4b9-4b42-a47b-3e16bb69e8f2',
'71c14f59-35d5-41f2-a017-e108d2d9f127',
......@@ -100,28 +102,29 @@ class APIListViewTestMixin(object):
list_name = 'list'
default_ids = []
always_exclude = ['created']
test_post_method = False
def path(self, query_data=None):
query_data = query_data or {}
concat_query_data = {param: ','.join(arg) for param, arg in query_data.items() if arg}
query_string = '?{}'.format(urlencode(concat_query_data)) if concat_query_data else ''
return '/api/v0/{}/{}'.format(self.list_name, query_string)
def validated_request(self, ids=None, fields=None, exclude=None, **extra_args):
params = [self.ids_param, 'fields', 'exclude']
args = [ids, fields, exclude]
data = {param: arg for param, arg in zip(params, args) if arg}
data.update(extra_args)
get_response = self.authenticated_get(self.path(data))
if self.test_post_method:
post_response = self.authenticated_post(self.path(), data=data)
self.assertEquals(get_response.status_code, post_response.status_code)
if 200 <= get_response.status_code < 300:
self.assertEquals(get_response.data, post_response.data)
return get_response
return '/api/v1/{}/{}'.format(self.list_name, query_string)
@classmethod
def build_request_data_dict(cls, ids=None, **kwargs):
data = {cls.ids_param: ids} if ids else {}
data.update({
key: value
for key, value in kwargs.iteritems()
if value not in [None, [None]]
})
return data
def validated_request(self, expected_status_code, ids=None, **kwargs):
request_data = self.build_request_data_dict(ids, **kwargs)
response = self.authenticated_get(self.path(request_data))
print '**** GET **** ' + response.content
self.assertEqual(response.status_code, expected_status_code)
return response.data
def create_model(self, model_id, **kwargs):
pass # implement in subclass
......@@ -148,20 +151,18 @@ class APIListViewTestMixin(object):
def _test_all_items(self, ids):
self.generate_data()
response = self.validated_request(ids=ids, exclude=self.always_exclude)
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(ids=ids))
data = self.validated_request(200, ids=ids, exclude=self.always_exclude)
self.assertItemsEqual(data, self.all_expected_results(ids=ids))
def _test_one_item(self, item_id):
self.generate_data()
response = self.validated_request(ids=[item_id], exclude=self.always_exclude)
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, [self.expected_result(item_id)])
actual_results = self.validated_request(200, ids=[item_id], exclude=self.always_exclude)
expected_results = [self.expected_result(item_id)]
self.assertItemsEqual(actual_results, expected_results)
def _test_fields(self, fields):
self.generate_data()
response = self.validated_request(fields=fields)
self.assertEquals(response.status_code, 200)
data = self.validated_request(200, fields=fields)
# remove fields not requested from expected results
expected_results = self.all_expected_results()
......@@ -169,13 +170,52 @@ class APIListViewTestMixin(object):
for field_to_remove in set(expected_result.keys()) - set(fields):
expected_result.pop(field_to_remove)
self.assertItemsEqual(response.data, expected_results)
self.assertItemsEqual(data, expected_results)
def test_no_items(self):
response = self.validated_request()
self.assertEquals(response.status_code, 404)
data = self.validated_request(200)
self.assertEqual(data, [])
def test_no_matching_items(self):
self.generate_data()
response = self.validated_request(ids=['no/items/found'])
self.assertEquals(response.status_code, 404)
data = self.validated_request(200, ids=['no/items/found'])
self.assertEqual(data, [])
class PostableAPIListViewTestMixin(APIListViewTestMixin):
max_ids_for_get = None
def validated_request(self, expected_status_code, ids=None, **kwargs):
request_data = self.build_request_data_dict(ids, **kwargs)
post_response = self.authenticated_post(self.path(), data=request_data)
print '**** POST **** ' + post_response.content
self.assertEqual(post_response.status_code, expected_status_code)
# If we can do a get, validate that the response is the same
if self.max_ids_for_get is None or (not ids) or len(ids) < self.max_ids_for_get:
get_data = super(PostableAPIListViewTestMixin, self).validated_request(
expected_status_code,
ids,
**kwargs
)
if expected_status_code >= 300:
return None
if {'next', 'prev'} & set(get_data.keys()):
for key in {'count', 'results', 'page'}:
self.assertEqual(get_data.get(key), post_response.data.get(key))
else:
self.assertDictEqual(get_data, post_response.data)
return post_response.data
class PaginatedAPIListViewTestMixin(APIListViewTestMixin):
def validated_request(self, expected_status_code, ids=None, extract_results=True, **kwargs):
data = super(PaginatedAPIListViewTestMixin, self).validated_request(
expected_status_code,
ids,
**kwargs
)
return data['results'] if extract_results and isinstance(data, dict) else data
import datetime
import ddt
from django_dynamic_fixture import G
import pytz
from django.conf import settings
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.v1 import models, serializers
from analytics_data_api.v1.tests.views import (
CourseSamples,
PaginatedAPIListViewTestMixin,
PostableAPIListViewTestMixin,
VerifyCourseIdMixin,
)
from analyticsdataserver.tests import TestCaseWithAuthentication
@ddt.ddt
class CourseSummariesViewTests(
VerifyCourseIdMixin,
PaginatedAPIListViewTestMixin,
PostableAPIListViewTestMixin,
TestCaseWithAuthentication,
):
model = models.CourseMetaSummaryEnrollment
model_id = 'course_id'
ids_param = 'course_ids'
serializer = serializers.CourseMetaSummaryEnrollmentSerializer
expected_summaries = []
list_name = 'course_summaries'
default_ids = CourseSamples.course_ids
always_exclude = ['created']
test_post_method = True
def setUp(self):
super(CourseSummariesViewTests, self).setUp()
self.now = datetime.datetime.utcnow()
self.maxDiff = None
def tearDown(self):
self.model.objects.all().delete()
def create_model(self, model_id, **kwargs):
model_kwargs = {
'course_id': model_id,
'catalog_course_title': 'Title',
'catalog_course': 'Catalog',
'start_time': datetime.datetime(2016, 10, 11, tzinfo=pytz.utc),
'end_time': datetime.datetime(2016, 12, 18, tzinfo=pytz.utc),
'pacing_type': 'instructor',
'availability': None,
'count': 5,
'cumulative_count': 10,
'count_change_7_days': 1,
'passing_users': 1,
'create': self.now
}
model_kwargs.update(kwargs)
for mode in kwargs['modes']:
G(self.model, enrollment_mode=mode, **model_kwargs)
# Create a link from this course to programs
program_ids = kwargs['programs'] if 'programs' in kwargs else [CourseSamples.program_ids[0]]
for i, program_id in enumerate(program_ids or []):
G(
models.CourseProgramMetadata,
course_id=model_id,
program_id=program_id,
program_type='Demo',
program_title=('Test #' + str(i)),
)
def generate_data(self, ids=None, modes=None, availability='Current', **kwargs):
"""Generate course summary data"""
if modes is None:
modes = enrollment_modes.ALL
super(CourseSummariesViewTests, self).generate_data(ids=ids, modes=modes, availability=availability, **kwargs)
def expected_result(self, item_id, modes=None, availability='Current'): # pylint: disable=arguments-differ
"""Expected summary information for a course and modes to populate with data."""
summary = super(CourseSummariesViewTests, self).expected_result(item_id)
if modes is None:
modes = enrollment_modes.ALL
num_modes = len(modes)
count_factor = 5
cumulative_count_factor = 10
count_change_factor = 1
summary.update([
('catalog_course_title', 'Title'),
('catalog_course', 'Catalog'),
('start_date', datetime.datetime(2016, 10, 11, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT)),
('end_date', datetime.datetime(2016, 12, 18, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT)),
('pacing_type', 'instructor'),
('availability', availability),
('count', count_factor * num_modes),
('cumulative_count', cumulative_count_factor * num_modes),
('count_change_7_days', count_change_factor * num_modes),
('verified_enrollment', count_factor if 'verified' in modes else 0),
('passing_users', count_change_factor * num_modes),
('enrollment_modes', {}),
])
summary['enrollment_modes'].update({
mode: {
'count': count_factor,
'cumulative_count': cumulative_count_factor,
'count_change_7_days': count_change_factor,
'passing_users': count_change_factor,
} for mode in modes
})
summary['enrollment_modes'].update({
mode: {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0,
'passing_users': 0,
} for mode in set(enrollment_modes.ALL) - set(modes)
})
no_prof = summary['enrollment_modes'].pop(enrollment_modes.PROFESSIONAL_NO_ID)
prof = summary['enrollment_modes'].get(enrollment_modes.PROFESSIONAL)
prof.update({
'count': prof['count'] + no_prof['count'],
'cumulative_count': prof['cumulative_count'] + no_prof['cumulative_count'],
'count_change_7_days': prof['count_change_7_days'] + no_prof['count_change_7_days'],
'passing_users': prof['passing_users'] + no_prof['passing_users'],
})
summary['programs'] = [CourseSamples.program_ids[0]]
return summary
def all_expected_results(self, ids=None, modes=None, availability='Current'): # pylint: disable=arguments-differ
if modes is None:
modes = enrollment_modes.ALL
return super(CourseSummariesViewTests, self).all_expected_results(
ids=ids,
modes=modes,
availability=availability
)
@ddt.data(
None,
CourseSamples.course_ids,
['not/real/course'].extend(CourseSamples.course_ids),
)
def test_all_courses(self, course_ids):
self._test_all_items(course_ids)
@ddt.data(*CourseSamples.course_ids)
def test_one_course(self, course_id):
self._test_one_item(course_id)
@ddt.data(
['availability'],
['enrollment_mode', 'course_id'],
)
def test_fields(self, fields):
self._test_fields(fields)
@ddt.data(
[enrollment_modes.VERIFIED],
[enrollment_modes.HONOR, enrollment_modes.PROFESSIONAL],
)
def test_empty_modes(self, modes):
self.generate_data(modes=modes)
results = self.validated_request(200, exclude=self.always_exclude)
self.assertItemsEqual(results, self.all_expected_results(modes=modes))
@ddt.data(
['malformed-course-id'],
[CourseSamples.course_ids[0], 'malformed-course-id'],
)
def test_bad_course_id(self, course_ids):
data = {self.ids_param: course_ids}
response = self.authenticated_get(self.path(data))
self.verify_bad_course_id(response)
response = self.authenticated_post(self.path(), data=data)
self.verify_bad_course_id(response)
def test_collapse_upcoming(self):
self.generate_data(availability='Starting Soon')
self.generate_data(ids=['foo/bar/baz'], availability='Upcoming')
actual_results = self.validated_request(200, exclude=self.always_exclude)
expected_results = (
self.all_expected_results(availability='Upcoming') +
self.all_expected_results(ids=['foo/bar/baz'], availability='Upcoming')
)
self.assertItemsEqual(actual_results, expected_results)
def test_programs(self):
self.generate_data()
actual_results = self.validated_request(200, exclude=self.always_exclude[:1])
expected_results = self.all_expected_results()
self.assertItemsEqual(actual_results, expected_results)
@ddt.data(['passing_users', 'count_change_7_days'], ['passing_users'])
def test_exclude(self, fields):
self.generate_data()
results = self.validated_request(200, exclude=fields)
for field in fields:
self.assertEquals(str(results).count(field), 0)
@ddt.data(
{
# Case 1 -- We can:
# * Sort numeric values, including negative ones
# * Specify an ascending sort order
# * Specify a page size AND a page
'order_by': ('count_change_7_days', 'count_change_7_days'),
'values': [10, 5, 15, -5],
'sort_order': 'asc',
'page': 1,
'page_size': 2,
'expected_order': [3, 1],
},
{
# Case 2 -- We can:
# * Sort dates, including None (which should act as min datetime)
# * Specify a descending sort order
# * NOT specify a page size, and get the max size (up to 100)
# * Specify a page
'order_by': ('start_time', 'start_date'),
'values': [
datetime.datetime(2016, 1, 1, tzinfo=pytz.utc),
None,
datetime.datetime(2018, 1, 1, tzinfo=pytz.utc),
datetime.datetime(2017, 1, 1, tzinfo=pytz.utc),
],
'sort_order': 'desc',
'page': 1,
'expected_order': [2, 3, 0, 1],
},
{
# Case 3 -- We can:
# * Sort strings, including None/empty (which should act as maximum string)
# * NOT specify an order, defaulting to ascending
# * Specify a page size AND a page
'order_by': ('catalog_course_title', 'catalog_course_title'),
'values': ['Zoology 101', '', None, 'Anthropology 101'],
'page_size': 1,
'page': 2,
'expected_order': [0],
},
{
# Case 4 -- We can:
# * Sort ints, including zero
# * NOT specify an order, defaulting to ascending
# * Specify a page size larger than the count, and get all results
# * Specify a page size
'order_by': ('passing_users', 'passing_users'),
'values': [0, 1, 2, 3],
'page_size': 50,
'page': 1,
'expected_order': [0, 1, 2, 3],
},
{
# Case 5 -- We get a 400 if we pass in an invalid order_by
'order_by': ('count', 'BAD_ORDER_BY'),
'values': [0, 0, 0, 0],
'expected_status_code': 400,
},
{
# Case 6 -- We get a 400 if we pass in an invalid sort_order
'order_by': ('count', 'count'),
'values': [0, 0, 0, 0],
'sort_order': 'BAD_SORT_ORDER',
'expected_status_code': 400,
},
{
# Case 7 -- We get a 200 if we pass in a negative page size
'page_size': -1,
'page': 1,
'expected_status_code': 200,
},
{
# Case 8 -- We get a 200 if we pass in a zero page size
'page_size': 0,
'page': 1,
'expected_status_code': 200,
},
{
# Case 9 -- We get a 200 if we pass in a too-large page size
'page_size': 200,
'page': 1,
'expected_status_code': 200,
},
{
# Case 10 -- We get a 200 if we pass in a non-int page size
'page_size': 'BAD_PAGE_SIZE',
'page': 1,
'expected_status_code': 200,
},
{
# Case 11 -- We get a 404 if we pass in an invalid page
'page_size': 50,
'page': 2,
'expected_status_code': 404,
},
{
# Case 12 -- We get a 404 if we pass in a non-int page
'page': 'BAD_PAGE',
'expected_status_code': 404,
},
{
# Case 12 -- We get a 404 if we don't pass in a page
'expected_status_code': 404,
},
)
@ddt.unpack
def test_sorting_and_pagination(
self,
order_by=(None, None),
values=None,
sort_order=None,
page=None,
page_size=None,
expected_status_code=200,
expected_order=None,
):
# Create models in order with course IDs and given values
for course_id, value in zip(CourseSamples.four_course_ids, values or [None] * 4):
self.generate_data(
ids=[course_id],
**({order_by[0]: value} if order_by[0] else {})
)
# Perform the request, checking the response code
data = self.validated_request(
expected_status_code,
order_by=[order_by[1]],
sort_order=[sort_order],
fields=['course_id'],
page_size=[str(page_size)],
page=[str(page)],
extract_results=False,
)
if expected_status_code >= 300:
return
# Make sure the total count is 4
self.assertEqual(data['count'], 4)
# Make sure the page size is right
try:
expected_page_size = int(page_size)
except (ValueError, TypeError):
expected_page_size = 4
if expected_page_size < 1 or expected_page_size > 4:
expected_page_size = 4
actual_page_size = len(data['results'])
self.assertEqual(expected_page_size, actual_page_size)
# If we are checking order, make sure it's right
if expected_order:
actual_order = [
CourseSamples.four_course_ids.index(result['course_id'])
for result in data['results']
]
self.assertEqual(actual_order, expected_order)
filter_test_dicts = [
{
'ids': ['course-v1:a+b+c'],
'catalog_course_title': 'New Course ABC',
'availability': 'Upcoming',
'pacing_type': 'self_paced',
'programs': ['program-1', 'program-2'],
},
{
'ids': ['b/c/d'],
'catalog_course_title': 'Old Course BCD',
'availability': 'unknown',
'pacing_type': 'instructor_paced',
'programs': ['program-1'],
},
{
'ids': ['ccx-v1:c+d+e'],
'catalog_course_title': 'CCX Course CDE',
'availability': None,
'pacing_type': None,
'programs': [],
},
]
@ddt.data(
{
# Case 1: If no search/filters, all are returned
'expected_indices': frozenset([0, 1, 2]),
},
{
# Case 2: Can search in course IDs w/ special symbols
'text_search': '+',
'expected_indices': frozenset([0, 2]),
},
{
# Case 3: Can search in course titles, case insensitive
'text_search': 'cOURSE',
'expected_indices': frozenset([0, 1, 2]),
},
{
# Case 4: No search results
'text_search': 'XYZ',
'expected_indices': frozenset(),
},
{
# Case 5: Can filter by availability, and None availabilities
# are returned by 'unknown' filter
'availability': ['unknown'],
'expected_indices': frozenset([1, 2]),
},
{
# Case 6: Can filter by multiple availabilities
'availability': ['Upcoming', 'Current'],
'expected_indices': frozenset([0]),
},
{
# Case 7: Can filter by a single pacing type
'pacing_type': ['self_paced'],
'expected_indices': frozenset([0]),
},
{
# Case 8: Can filter by a multiple pacing types
'pacing_type': ['self_paced', 'instructor_paced'],
'expected_indices': frozenset([0, 1]),
},
{
# Case 9: Can filter by program
'program_ids': ['program-1'],
'expected_indices': frozenset([0, 1]),
},
{
# Case 10: Can filter by multiple programs, even if one doesn't exist
'program_ids': ['program-2', 'program-3'],
'expected_indices': frozenset([0]),
},
{
# Case 11: Bad filter value returns 400
'pacing_type': ['BAD_PACING_TYPE'],
'expected_status_code': 400,
},
)
@ddt.unpack
def test_filtering_and_searching(
self,
expected_indices=None,
text_search=None,
expected_status_code=200,
**filters
):
for test_dict in self.filter_test_dicts:
self.generate_data(**test_dict)
results = self.validated_request(expected_status_code, text_search=[text_search], **filters)
if expected_status_code >= 300:
return
actual_ids = frozenset(result['course_id'] for result in results)
expected_ids = set()
for index in expected_indices:
expected_ids.add(self.filter_test_dicts[index]['ids'][0])
self.assertEqual(actual_ids, expected_ids)
import random
from urllib import quote_plus
import ddt
from django_dynamic_fixture import G
from analytics_data_api.v1 import models
from analytics_data_api.v1.tests.views import CourseSamples
from analyticsdataserver.tests import TestCaseWithAuthentication
@ddt.ddt
class CourseTotalsViewTests(TestCaseWithAuthentication):
SEED_DATA_BOUNDS = (10000, 100000)
OPTIONAL_COURSE_MODES = ['honor', 'credit', 'professional', 'professional-no-id']
@classmethod
def _get_counts(cls):
"""
Returns a triplet of viable (count, cumulative_count, count_change_7_days) numbers
"""
count = random.randint(CourseTotalsViewTests.SEED_DATA_BOUNDS[0], CourseTotalsViewTests.SEED_DATA_BOUNDS[1])
cumulative_count = random.randint(count, int(count * 1.5))
count_change_7_days = random.randint(int(count * .1), int(count * .3))
return (count, cumulative_count, count_change_7_days)
@classmethod
def setUpClass(cls):
super(CourseTotalsViewTests, cls).setUpClass()
cls.test_data = {
id: {
'count': 0,
'cumulative_count': 0,
'verified_enrollment': 0,
'count_change_7_days': 0
} for id in CourseSamples.course_ids
} # pylint: disable=attribute-defined-outside-init
for course in cls.test_data:
modes = ['verified'] # No choice here, everyone gets a verified mode
modes = modes + random.sample(CourseTotalsViewTests.OPTIONAL_COURSE_MODES, random.randint(1, 3))
for mode in modes:
counts = cls._get_counts()
cls.test_data[course]['count'] += counts[0]
if mode == 'verified':
cls.test_data[course]['verified_enrollment'] += counts[0]
cls.test_data[course]['cumulative_count'] += counts[1]
cls.test_data[course]['count_change_7_days'] += counts[2]
G(
models.CourseMetaSummaryEnrollment,
course_id=course,
enrollment_mode=mode,
count=counts[0],
cumulative_count=counts[1],
count_change_7_days=counts[2]
)
def _get_data(self, course_ids):
url = '/api/v1/course_totals/'
if course_ids:
url += '?course_ids={}'.format(",".join(map(quote_plus, course_ids)))
return self.authenticated_get(url)
@ddt.data(
None,
CourseSamples.course_ids,
[CourseSamples.course_ids[1]],
[CourseSamples.course_ids[0], CourseSamples.course_ids[2]]
)
def test_get(self, course_ids):
response = self._get_data(course_ids) # get response first so we can set expected if course_ids==[]
if not course_ids:
course_ids = CourseSamples.course_ids
expected = {
'count': sum(
[self.test_data[course]['count'] for course in course_ids]
),
'cumulative_count': sum(
[self.test_data[course]['cumulative_count'] for course in course_ids]
),
'verified_enrollment': sum(
[self.test_data[course]['verified_enrollment'] for course in course_ids]
),
'count_change_7_days': sum(
[self.test_data[course]['count_change_7_days'] for course in course_ids]
)
}
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, expected)
......@@ -17,8 +17,8 @@ from mock import patch, Mock
from analytics_data_api.constants import country, enrollment_modes, genders
from analytics_data_api.constants.country import get_country
from analytics_data_api.v0 import models
from analytics_data_api.v0.tests.views import CourseSamples, VerifyCsvResponseMixin
from analytics_data_api.v1 import models
from analytics_data_api.v1.tests.views import CourseSamples, VerifyCsvResponseMixin
from analytics_data_api.utils import get_filename_safe_course_id
from analyticsdataserver.tests import TestCaseWithAuthentication
......@@ -43,7 +43,7 @@ class DefaultFillTestMixin(object):
@ddt.ddt
class CourseViewTestCaseMixin(VerifyCsvResponseMixin):
model = None
api_root_path = '/api/v0/'
api_root_path = '/api/v1/'
path = None
order_by = []
csv_filename_slug = None
......@@ -184,12 +184,12 @@ class CourseActivityLastWeekTest(TestCaseWithAuthentication):
@ddt.data(*CourseSamples.course_ids)
def test_activity(self, course_id):
self.generate_data(course_id)
response = self.authenticated_get(u'/api/v0/courses/{0}/recent_activity'.format(course_id))
response = self.authenticated_get(u'/api/v1/courses/{0}/recent_activity'.format(course_id))
self.assertEquals(response.status_code, 200)
self.assertEquals(response.data, self.get_activity_record(course_id=course_id))
def assertValidActivityResponse(self, course_id, activity_type, count):
response = self.authenticated_get(u'/api/v0/courses/{0}/recent_activity?activity_type={1}'.format(
response = self.authenticated_get(u'/api/v1/courses/{0}/recent_activity?activity_type={1}'.format(
course_id, activity_type))
self.assertEquals(response.status_code, 200)
self.assertEquals(response.data, self.get_activity_record(course_id=course_id, activity_type=activity_type,
......@@ -212,14 +212,14 @@ class CourseActivityLastWeekTest(TestCaseWithAuthentication):
@ddt.data(*CourseSamples.course_ids)
def test_activity_auth(self, course_id):
self.generate_data(course_id)
response = self.client.get(u'/api/v0/courses/{0}/recent_activity'.format(course_id), follow=True)
response = self.client.get(u'/api/v1/courses/{0}/recent_activity'.format(course_id), follow=True)
self.assertEquals(response.status_code, 401)
@ddt.data(*CourseSamples.course_ids)
def test_url_encoded_course_id(self, course_id):
self.generate_data(course_id)
url_encoded_course_id = urllib.quote_plus(course_id)
response = self.authenticated_get(u'/api/v0/courses/{}/recent_activity'.format(url_encoded_course_id))
response = self.authenticated_get(u'/api/v1/courses/{}/recent_activity'.format(url_encoded_course_id))
self.assertEquals(response.status_code, 200)
self.assertEquals(response.data, self.get_activity_record(course_id=course_id))
......@@ -238,23 +238,23 @@ class CourseActivityLastWeekTest(TestCaseWithAuthentication):
def test_unknown_activity(self, course_id):
self.generate_data(course_id)
activity_type = 'missing_activity_type'
response = self.authenticated_get(u'/api/v0/courses/{0}/recent_activity?activity_type={1}'.format(
response = self.authenticated_get(u'/api/v1/courses/{0}/recent_activity?activity_type={1}'.format(
course_id, activity_type))
self.assertEquals(response.status_code, 404)
def test_unknown_course_id(self):
response = self.authenticated_get(u'/api/v0/courses/{0}/recent_activity'.format('foo'))
response = self.authenticated_get(u'/api/v1/courses/{0}/recent_activity'.format('foo'))
self.assertEquals(response.status_code, 404)
def test_missing_course_id(self):
response = self.authenticated_get(u'/api/v0/courses/recent_activity')
response = self.authenticated_get(u'/api/v1/courses/recent_activity')
self.assertEquals(response.status_code, 404)
@ddt.data(*CourseSamples.course_ids)
def test_label_parameter(self, course_id):
self.generate_data(course_id)
activity_type = 'played_video'
response = self.authenticated_get(u'/api/v0/courses/{0}/recent_activity?label={1}'.format(
response = self.authenticated_get(u'/api/v1/courses/{0}/recent_activity?label={1}'.format(
course_id, activity_type))
self.assertEquals(response.status_code, 200)
self.assertEquals(response.data, self.get_activity_record(course_id=course_id, activity_type=activity_type,
......@@ -282,7 +282,7 @@ class CourseEnrollmentByBirthYearViewTests(CourseEnrollmentViewTestCaseMixin, Te
@ddt.data(*CourseSamples.course_ids)
def test_get(self, course_id):
self.generate_data(course_id)
response = self.authenticated_get('/api/v0/courses/%s%s' % (course_id, self.path,))
response = self.authenticated_get('/api/v1/courses/%s%s' % (course_id, self.path,))
self.assertEquals(response.status_code, 200)
expected = self.format_as_response(*self.model.objects.filter(date=self.date))
......@@ -577,7 +577,7 @@ class CourseProblemsListViewTests(TestCaseWithAuthentication):
"""
Retrieve data for the specified course.
"""
url = '/api/v0/courses/{}/problems/'.format(course_id)
url = '/api/v1/courses/{}/problems/'.format(course_id)
return self.authenticated_get(url)
@ddt.data(*CourseSamples.course_ids)
......@@ -642,7 +642,7 @@ class CourseProblemsAndTagsListViewTests(TestCaseWithAuthentication):
"""
Retrieve data for the specified course.
"""
url = '/api/v0/courses/{}/problems_and_tags/'.format(course_id)
url = '/api/v1/courses/{}/problems_and_tags/'.format(course_id)
return self.authenticated_get(url)
@ddt.data(*CourseSamples.course_ids)
......@@ -721,7 +721,7 @@ class CourseVideosListViewTests(TestCaseWithAuthentication):
"""
Retrieve videos for a specified course.
"""
url = '/api/v0/courses/{}/videos/'.format(course_id)
url = '/api/v1/courses/{}/videos/'.format(course_id)
return self.authenticated_get(url)
@ddt.data(*CourseSamples.course_ids)
......@@ -776,7 +776,7 @@ class CourseVideosListViewTests(TestCaseWithAuthentication):
@ddt.ddt
class CourseReportDownloadViewTests(TestCaseWithAuthentication):
path = '/api/v0/courses/{course_id}/reports/{report_name}'
path = '/api/v1/courses/{course_id}/reports/{report_name}'
@patch('django.core.files.storage.default_storage.exists', Mock(return_value=False))
@ddt.data(*CourseSamples.course_ids)
......
......@@ -11,14 +11,14 @@ from rest_framework import status
from analyticsdataserver.tests import TestCaseWithAuthentication
from analytics_data_api.constants.engagement_events import (ATTEMPTED, COMPLETED, CONTRIBUTED, DISCUSSION,
PROBLEM, VIDEO, VIEWED)
from analytics_data_api.v0 import models
from analytics_data_api.v0.tests.views import CourseSamples, VerifyCourseIdMixin
from analytics_data_api.v1 import models
from analytics_data_api.v1.tests.views import CourseSamples, VerifyCourseIdMixin
@ddt.ddt
class EngagementTimelineTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
DEFAULT_USERNAME = 'ed_xavier'
path_template = '/api/v0/engagement_timelines/{}/?course_id={}'
path_template = '/api/v1/engagement_timelines/{}/?course_id={}'
def create_engagement(self, course_id, entity_type, event_type, entity_id, count, date=None):
"""Create a ModuleEngagement model"""
......@@ -158,7 +158,7 @@ class EngagementTimelineTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
self.assertDictEqual(json.loads(response.content), expected)
def test_no_course_id(self):
base_path = '/api/v0/engagement_timelines/{}'
base_path = '/api/v1/engagement_timelines/{}'
response = self.authenticated_get((base_path).format('ed_xavier'))
self.verify_no_course_id(response)
......
......@@ -18,9 +18,9 @@ from django.test import override_settings
from analyticsdataserver.tests import TestCaseWithAuthentication
from analytics_data_api.constants import engagement_events
from analytics_data_api.v0.models import ModuleEngagementMetricRanges
from analytics_data_api.v0.views import CsvViewMixin, PaginatedHeadersMixin
from analytics_data_api.v0.tests.views import (
from analytics_data_api.v1.models import ModuleEngagementMetricRanges
from analytics_data_api.v1.views.base import CsvViewMixin, PaginatedHeadersMixin
from analytics_data_api.v1.tests.views import (
CourseSamples, VerifyCourseIdMixin, VerifyCsvResponseMixin,
)
......@@ -141,7 +141,7 @@ class LearnerAPITestMixin(CsvViewMixin):
course_q = urlencode({'course_id': course_id})
page_q = '&page={}'.format(page) if page and page > 1 else ''
page_size_q = '&page_size={}'.format(page_size) if page_size > 0 else ''
return 'http://testserver/api/v0/learners/?{course_q}{page_q}{page_size_q}'.format(
return 'http://testserver/api/v1/learners/?{course_q}{page_q}{page_size_q}'.format(
course_q=course_q, page_q=page_q, page_size_q=page_size_q,
)
......@@ -149,7 +149,7 @@ class LearnerAPITestMixin(CsvViewMixin):
@ddt.ddt
class LearnerTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestCaseWithAuthentication):
"""Tests for the single learner endpoint."""
path_template = '/api/v0/learners/{}/?course_id={}'
path_template = '/api/v1/learners/{}/?course_id={}'
@ddt.data(
('ed_xavier', 'Edward Xavier', 'edX/DemoX/Demo_Course', 'honor', ['has_potential'], 'Team edX',
......@@ -229,7 +229,7 @@ class LearnerTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestCaseWithAuthent
}
self.assertDictEqual(expected, response.data)
@patch('analytics_data_api.v0.models.RosterEntry.get_course_user', Mock(return_value=[]))
@patch('analytics_data_api.v1.models.RosterEntry.get_course_user', Mock(return_value=[]))
def test_not_found(self):
user_name = 'a_user'
course_id = 'edX/DemoX/Demo_Course'
......@@ -242,7 +242,7 @@ class LearnerTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestCaseWithAuthent
self.assertDictEqual(json.loads(response.content), expected)
def test_no_course_id(self):
base_path = '/api/v0/learners/{}'
base_path = '/api/v1/learners/{}'
response = self.authenticated_get((base_path).format('ed_xavier'))
self.verify_no_course_id(response)
......@@ -263,7 +263,7 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
def _get(self, course_id, **query_params):
"""Helper to send a GET request to the API."""
query_params['course_id'] = course_id
return self.authenticated_get('/api/v0/learners/', query_params)
return self.authenticated_get('/api/v1/learners/', query_params)
def assert_learners_returned(self, response, expected_learners):
"""
......@@ -494,7 +494,7 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
)
@ddt.unpack
def test_bad_request(self, parameters, expected_error_code, expected_status_code=400):
response = self.authenticated_get('/api/v0/learners/', parameters)
response = self.authenticated_get('/api/v1/learners/', parameters)
self.assertEqual(response.status_code, expected_status_code)
response_json = json.loads(response.content)
self.assertEqual(response_json.get('error_code', response_json.get('detail')), expected_error_code)
......@@ -508,7 +508,7 @@ class LearnerCsvListTests(LearnerAPITestMixin, VerifyCourseIdMixin,
super(LearnerCsvListTests, self).setUp()
self.course_id = 'edX/DemoX/Demo_Course'
self.create_update_index('2015-09-28')
self.path = '/api/v0/learners/'
self.path = '/api/v1/learners/'
def test_empty_csv(self):
""" Verify the endpoint returns data that has been properly converted to CSV. """
......@@ -652,7 +652,7 @@ class CourseLearnerMetadataTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestC
def _get(self, course_id):
"""Helper to send a GET request to the API."""
return self.authenticated_get('/api/v0/course_learner_metadata/{}/'.format(course_id))
return self.authenticated_get('/api/v1/course_learner_metadata/{}/'.format(course_id))
def get_expected_json(self, course_id, segments, enrollment_modes, cohorts):
expected_json = self._get_full_engagement_ranges(course_id)
......@@ -666,7 +666,7 @@ class CourseLearnerMetadataTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestC
self.assertDictEqual(json.loads(response.content), expected_data)
def test_no_course_id(self):
response = self.authenticated_get('/api/v0/course_learner_metadata/')
response = self.authenticated_get('/api/v1/course_learner_metadata/')
self.assertEqual(response.status_code, 404)
@ddt.data(
......
......@@ -9,8 +9,8 @@ import json
from django_dynamic_fixture import G
from analytics_data_api.v0 import models
from analytics_data_api.v0.serializers import ProblemFirstLastResponseAnswerDistributionSerializer, \
from analytics_data_api.v1 import models
from analytics_data_api.v1.serializers import ProblemFirstLastResponseAnswerDistributionSerializer, \
GradeDistributionSerializer, SequentialOpenDistributionSerializer
from analyticsdataserver.tests import TestCaseWithAuthentication
......@@ -94,7 +94,7 @@ class AnswerDistributionTests(TestCaseWithAuthentication):
def test_nonconsolidated_get(self):
""" Verify that answers which should not be consolidated are not. """
response = self.authenticated_get('/api/v0/problems/%s%s' % (self.module_id2, self.path))
response = self.authenticated_get('/api/v1/problems/%s%s' % (self.module_id2, self.path))
self.assertEquals(response.status_code, 200)
expected_data = models.ProblemFirstLastResponseAnswerDistribution.objects.filter(module_id=self.module_id2)
......@@ -111,7 +111,7 @@ class AnswerDistributionTests(TestCaseWithAuthentication):
def test_consolidated_get(self):
""" Verify that valid consolidation does occur. """
response = self.authenticated_get(
'/api/v0/problems/{0}{1}'.format(self.module_id1, self.path))
'/api/v1/problems/{0}{1}'.format(self.module_id1, self.path))
self.assertEquals(response.status_code, 200)
expected_data = [self.ad1, self.ad3]
......@@ -132,7 +132,7 @@ class AnswerDistributionTests(TestCaseWithAuthentication):
self.assertEquals(set(response.data), set(expected_data))
def test_get_404(self):
response = self.authenticated_get('/api/v0/problems/%s%s' % ("DOES-NOT-EXIST", self.path))
response = self.authenticated_get('/api/v1/problems/%s%s' % ("DOES-NOT-EXIST", self.path))
self.assertEquals(response.status_code, 404)
......@@ -152,7 +152,7 @@ class GradeDistributionTests(TestCaseWithAuthentication):
)
def test_get(self):
response = self.authenticated_get('/api/v0/problems/%s%s' % (self.module_id, self.path))
response = self.authenticated_get('/api/v1/problems/%s%s' % (self.module_id, self.path))
self.assertEquals(response.status_code, 200)
expected_dict = GradeDistributionSerializer(self.ad1).data
......@@ -161,7 +161,7 @@ class GradeDistributionTests(TestCaseWithAuthentication):
self.assertDictEqual(actual_list[0], expected_dict)
def test_get_404(self):
response = self.authenticated_get('/api/v0/problems/%s%s' % ("DOES-NOT-EXIST", self.path))
response = self.authenticated_get('/api/v1/problems/%s%s' % ("DOES-NOT-EXIST", self.path))
self.assertEquals(response.status_code, 404)
......@@ -181,7 +181,7 @@ class SequentialOpenDistributionTests(TestCaseWithAuthentication):
)
def test_get(self):
response = self.authenticated_get('/api/v0/problems/%s%s' % (self.module_id, self.path))
response = self.authenticated_get('/api/v1/problems/%s%s' % (self.module_id, self.path))
self.assertEquals(response.status_code, 200)
expected_dict = SequentialOpenDistributionSerializer(self.ad1).data
......@@ -190,5 +190,5 @@ class SequentialOpenDistributionTests(TestCaseWithAuthentication):
self.assertDictEqual(actual_list[0], expected_dict)
def test_get_404(self):
response = self.authenticated_get('/api/v0/problems/%s%s' % ("DOES-NOT-EXIST", self.path))
response = self.authenticated_get('/api/v1/problems/%s%s' % ("DOES-NOT-EXIST", self.path))
self.assertEquals(response.status_code, 404)
......@@ -2,8 +2,8 @@ import datetime
import ddt
from django_dynamic_fixture import G
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.tests.views import CourseSamples, APIListViewTestMixin
from analytics_data_api.v1 import models, serializers
from analytics_data_api.v1.tests.views import CourseSamples, APIListViewTestMixin
from analyticsdataserver.tests import TestCaseWithAuthentication
......@@ -97,6 +97,6 @@ class ProgramsViewTests(TestCaseWithAuthentication, APIListViewTestMixin):
@ddt.unpack
def test_all_programs_multi_courses(self, program_ids, course_ids):
self.generate_data(ids=program_ids, course_ids=course_ids)
response = self.validated_request(ids=program_ids, exclude=self.always_exclude)
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(ids=program_ids, course_ids=course_ids))
actual_data = self.validated_request(200, ids=program_ids, exclude=self.always_exclude)
expected_data = self.all_expected_results(ids=program_ids, course_ids=course_ids)
self.assertItemsEqual(actual_data, expected_data)
......@@ -4,9 +4,9 @@ from mock import Mock
from django.http import Http404
from django.test import TestCase
from analytics_data_api.v0.exceptions import CourseKeyMalformedError
from analytics_data_api.v0.tests.views import CourseSamples
import analytics_data_api.v0.views.utils as utils
from analytics_data_api.v1.exceptions import CourseKeyMalformedError
from analytics_data_api.v1.tests.views import CourseSamples
import analytics_data_api.v1.views.utils as utils
@ddt.ddt
......
......@@ -4,14 +4,14 @@ from django.conf import settings
from django.utils import timezone
from django_dynamic_fixture import G
from analytics_data_api.v0 import models
from analytics_data_api.v1 import models
from analyticsdataserver.tests import TestCaseWithAuthentication
class VideoTimelineTests(TestCaseWithAuthentication):
def _get_data(self, video_id=None):
return self.authenticated_get('/api/v0/videos/{}/timeline'.format(video_id))
return self.authenticated_get('/api/v1/videos/{}/timeline'.format(video_id))
def test_get(self):
# add a blank row, which shouldn't be included in results
......
......@@ -2,15 +2,17 @@ from django.conf.urls import url, include
from django.core.urlresolvers import reverse_lazy
from django.views.generic import RedirectView
COURSE_ID_PATTERN = r'(?P<course_id>[^/+]+[/+][^/+]+[/+][^/]+)'
urlpatterns = [
url(r'^courses/', include('analytics_data_api.v0.urls.courses', 'courses')),
url(r'^problems/', include('analytics_data_api.v0.urls.problems', 'problems')),
url(r'^videos/', include('analytics_data_api.v0.urls.videos', 'videos')),
url('^', include('analytics_data_api.v0.urls.learners', 'learners')),
url('^', include('analytics_data_api.v0.urls.course_summaries', 'course_summaries')),
url('^', include('analytics_data_api.v0.urls.programs', 'programs')),
url(r'^courses/', include('analytics_data_api.v1.urls.courses', 'courses')),
url(r'^problems/', include('analytics_data_api.v1.urls.problems', 'problems')),
url(r'^videos/', include('analytics_data_api.v1.urls.videos', 'videos')),
url('^', include('analytics_data_api.v1.urls.learners', 'learners')),
url('^', include('analytics_data_api.v1.urls.course_summaries', 'course_summaries')),
url('^', include('analytics_data_api.v1.urls.course_totals', 'course_totals')),
url('^', include('analytics_data_api.v1.urls.programs', 'programs')),
# pylint: disable=no-value-for-parameter
url(r'^authenticated/$', RedirectView.as_view(url=reverse_lazy('authenticated')), name='authenticated'),
......
from django.conf.urls import url
from analytics_data_api.v0.views import course_summaries as views
from analytics_data_api.v1.views import course_summaries as views
urlpatterns = [
url(r'^course_summaries/$', views.CourseSummariesView.as_view(), name='course_summaries'),
......
from django.conf.urls import url
from analytics_data_api.v1.views import course_totals as views
urlpatterns = [
url(r'^course_totals/$', views.CourseTotalsView.as_view(), name='course_totals'),
]
from django.conf.urls import url
from analytics_data_api.v0.urls import COURSE_ID_PATTERN
from analytics_data_api.v0.views import courses as views
from analytics_data_api.v1.urls import COURSE_ID_PATTERN
from analytics_data_api.v1.views import courses as views
COURSE_URLS = [
('activity', views.CourseActivityWeeklyView, 'activity'),
......
from django.conf.urls import url
from analytics_data_api.v0.urls import COURSE_ID_PATTERN
from analytics_data_api.v0.views import learners as views
from analytics_data_api.v1.urls import COURSE_ID_PATTERN
from analytics_data_api.v1.views import learners as views
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
......
......@@ -2,7 +2,7 @@ import re
from django.conf.urls import url
from analytics_data_api.v0.views import problems as views
from analytics_data_api.v1.views import problems as views
PROBLEM_URLS = [
('answer_distribution', views.ProblemResponseAnswerDistributionView, 'answer_distribution'),
......
from django.conf.urls import url
from analytics_data_api.v0.views import programs as views
from analytics_data_api.v1.views import programs as views
urlpatterns = [
url(r'^programs/$', views.ProgramsView.as_view(), name='programs'),
......
......@@ -2,7 +2,7 @@ import re
from django.conf.urls import url
from analytics_data_api.v0.views import videos as views
from analytics_data_api.v1.views import videos as views
VIDEO_URLS = [
('timeline', views.VideoTimelineView, 'timeline'),
......
from collections import namedtuple, OrderedDict
from itertools import groupby
from django.core.cache import caches
from django.utils import timezone
from rest_framework import serializers
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.utils import classproperty, join_dicts
from analytics_data_api.v1.exceptions import (
CourseNotSpecifiedError,
ParameterValueError,
)
from analytics_data_api.v1.views.utils import (
split_query_argument,
validate_course_id,
)
def _get_field(value, field, *args):
return (
value.get(field, *args)
if isinstance(value, dict)
else getattr(value, field, *args)
)
class CourseViewMixin(object):
"""
Captures the course_id from the url and validates it.
"""
course_id = None
def get(self, request, *args, **kwargs):
self.course_id = self.kwargs.get('course_id', request.query_params.get('course_id', None))
if not self.course_id:
raise CourseNotSpecifiedError()
validate_course_id(self.course_id)
return super(CourseViewMixin, self).get(request, *args, **kwargs)
class PaginatedHeadersMixin(object):
"""
If the response is paginated, then augment it with this response header:
* Link: list of next and previous pagination URLs, e.g.
<next_url>; rel="next", <previous_url>; rel="prev"
Format follows the github API convention:
https://developer.github.com/guides/traversing-with-pagination/
Useful with PaginatedCsvRenderer, so that previous/next links aren't lost when returning CSV data.
"""
# TODO: When we upgrade to Django REST API v3.1, define a custom DEFAULT_PAGINATION_CLASS
# instead of using this mechanism:
# http://www.django-rest-framework.org/api-guide/pagination/#header-based-pagination
def get(self, request, *args, **kwargs):
"""
Stores pagination links in a response header.
"""
response = super(PaginatedHeadersMixin, self).get(request, args, kwargs)
link = self.get_paginated_links(response.data)
if link:
response['Link'] = link
return response
@staticmethod
def get_paginated_links(data):
"""
Returns the links string.
"""
# Un-paginated data is returned as a list, not a dict.
next_url = None
prev_url = None
if isinstance(data, dict):
next_url = data.get('next')
prev_url = data.get('previous')
if next_url is not None and prev_url is not None:
link = '<{next_url}>; rel="next", <{prev_url}>; rel="prev"'
elif next_url is not None:
link = '<{next_url}>; rel="next"'
elif prev_url is not None:
link = '<{prev_url}>; rel="prev"'
else:
link = ''
return link.format(next_url=next_url, prev_url=prev_url)
class CsvViewMixin(object):
"""
Augments a text/csv response with this header:
* Content-Disposition: allows the client to download the response as a file attachment.
"""
# Default filename slug for CSV download files
filename_slug = 'report'
def get_csv_filename(self):
"""
Returns the filename for the CSV download.
"""
course_key = CourseKey.from_string(self.course_id)
course_id = u'-'.join([course_key.org, course_key.course, course_key.run])
now = timezone.now().replace(microsecond=0)
return u'{0}--{1}--{2}.csv'.format(course_id, now.isoformat(), self.filename_slug)
def finalize_response(self, request, response, *args, **kwargs):
"""
Append Content-Disposition header to CSV requests.
"""
if request.META.get('HTTP_ACCEPT') == u'text/csv':
response['Content-Disposition'] = u'attachment; filename={}'.format(self.get_csv_filename())
return super(CsvViewMixin, self).finalize_response(request, response, *args, **kwargs)
class TypedQueryParametersAPIViewMixin(object):
"""
Mixin for collecting parameters in a typed fashion.
To use, override collect_params. In it, use get_query_param to
get parameters, and set them as attributes on `self`.
Example:
def collect_params(self):
self.numbers = get_query_params('nums', list, possible_values=set(range(10)))
"""
def get(self, request, *args, **kwargs):
# Collect query paramters, and then call superclass's `get`.
# Returns 422 if any parameter values are rejected.
# (we don't use a docstring here because it messes with Swagger's UI)
try:
self.collect_params()
except ParameterValueError as e:
raise serializers.ValidationError(detail=e.message)
return super(TypedQueryParametersAPIViewMixin, self).get(request, *args, **kwargs)
def collect_params(self):
pass
def get_query_param(self, name, value_type, possible_values=None, none_okay=True):
"""
Extracts an argument from an HTTP request.
Arguments:
name (str): Name of argument
value_type (type): Expected type of argument value.
For list, frozenset, and set: JSON value is parsed and converted to type.
For other types: The type is used as a function that the JSON string is
passed directly to. For example, if `int` is passed in, we call
`int(<paramter_json_string>)`.
Note that this may not work for all types. This method may need to be
modified in the future to support more types.
possible_values (set|NoneType): Values that are allowed. If None,
all values are allowed. If value_type is a collection type,
possible_values refer to allowed elements.
none_okay: Whether an empty/not-given query paramter is acceptable.
Returns: value of type value_type
Raises:
ParamterValueError: Parameter is wrong type, not in possible_values,
or None/nonexistent when none_okay=False
"""
param = self.request.query_params.get(name)
if param and issubclass(value_type, (list, frozenset, set)):
param = split_query_argument(param)
value = value_type(param) if param else None
return self.validate_query_param(name, value, possible_values, none_okay)
def has_query_param(self, name):
return name in self.request.query_params
@staticmethod
def validate_query_param(name, value, possible_values, none_okay):
if none_okay and value is None:
return value
value_good = possible_values is None or (
frozenset(value).issubset(possible_values)
if isinstance(value, frozenset) or isinstance(value, list)
else value in possible_values
)
if not value_good:
raise ParameterValueError(
'Invalid value of {0}: {1}. Expected to be in: {2}'.format(
name,
value,
', '.join(possible_values)
)
)
return value
class PostAsGetAPIViewMixin(TypedQueryParametersAPIViewMixin):
"""
Mixin that handles POST requests and treats them as GET requests.
Provides an interface for getting parameters that is equivalent to
that of GET requests.
"""
def post(self, request, *args, **kwargs):
return self.get(request, *args, **kwargs)
def get_query_param(self, name, value_type, possible_values=None, none_okay=True):
"""
Overridden from TypedQueryParametersAPIViewMixin.
"""
if self.request.method == 'GET':
value = super(PostAsGetAPIViewMixin, self).get_query_param(name, value_type)
else:
if issubclass(value_type, (list, frozenset)):
param = self.request.data.getlist(name)
else:
param = self.request.data.get(name)
value = value_type(param) if param else None
return self.validate_query_param(name, value, possible_values, none_okay=True)
def has_query_param(self, name):
"""
Overridden from TypedQueryParametersAPIViewMixin.
"""
return (
super(PostAsGetAPIViewMixin, self).has_query_param(name)
if self.request.method == 'GET'
else (name in self.request.data)
)
class DynamicFieldsAPIViewMixin(TypedQueryParametersAPIViewMixin):
"""
Mixin for allowing client to blacklist or whitelist response fields.
`include_param` is used to specify a list of response fields to include.
`exclude_param` is used to specify a list of response fields to exclude.
"""
# Optionally override in subclass
include_param = 'fields'
exclude_param = 'exclude'
def __init__(self, *args, **kwargs):
super(DynamicFieldsAPIViewMixin, self).__init__(*args, **kwargs)
# We must define these here as None, because we use them
# in get_serializer_kwargs, which must be available to
# Swagger.
self.fields_to_include = None
self.fields_to_exclude = None
def collect_params(self):
"""
Overridden from TypedQueryParametersAPIViewMixin.
"""
self.fields_to_include = self.get_query_param(self.include_param, frozenset)
self.fields_to_exclude = self.get_query_param(self.exclude_param, frozenset)
super(DynamicFieldsAPIViewMixin, self).collect_params()
def get_serializer(self, *args, **kwargs):
new_kwargs = join_dicts(
kwargs,
self.get_serializer_kwargs(),
{'context': self.get_serializer_context()},
)
return self.get_serializer_class()(*args, **new_kwargs)
def get_serializer_kwargs(self):
"""
Overriden from APIView (not in this mixin's hierarchy).
"""
try:
super_kwargs = super(DynamicFieldsAPIViewMixin, self).get_serializer_kwargs()
except AttributeError:
super_kwargs = {}
my_kwargs = {
'fields': (
list(self.fields_to_include)
if self.fields_to_include
else None
),
'exclude': (
list(self.fields_to_exclude)
if self.fields_to_exclude
else None
),
}
return join_dicts(super_kwargs, my_kwargs)
class IDsAPIViewMixin(TypedQueryParametersAPIViewMixin):
"""
Mixin for allowing a list of IDs to be passed in as a parameter.
"""
# Optionally override in superclass
ids_param = 'ids'
def collect_params(self):
"""
Overriden from TypedQueryParmetersAPIViewMixin.
"""
self.ids = self.get_query_param(self.ids_param, frozenset)
self.validate_id_formats(self.ids)
super(IDsAPIViewMixin, self).collect_params()
@classmethod
def validate_id_formats(cls, ids):
"""
In subclass: raise an exception if IDs are malformed.
Optional to override; by default, does nothing.
Arguments:
ids (frozenset[str])
Raises:
subclass of Exception: one or IDs are malformed
"""
pass
class ListAPIViewMixinBase(IDsAPIViewMixin):
"""
Base mixin for returning a list of processed items.
"""
def get_queryset(self):
"""
Overriden from APIView (not in this mixin's inheritance hierarchy)
"""
return self.process_items(
(
self.load_items() if self.ids
else self.load_all_items()
)
).values()
def load_items(self):
"""
Load items, filtered by `self.ids`. Implement in subclass.
Returns: dict[str: T], where T is item type
Dictionary from item IDs to items.
"""
raise NotImplementedError('load_items not implemented in subclass')
@classmethod
def load_all_items(cls):
"""
Load ALL items. Implement in subclass.
Returns: dict[str: T], where T is item type
Dictionary from item IDs to items.
"""
raise NotImplementedError('load_all_items not implemented in subclass')
def process_items(self, items):
"""
Process items to be returned in API response.
Arguments:
items (dict[str: T]):
Returns: dict[str: T]
Note:
Make sure to call super(...).process_items(items), usually
before processing the items.
"""
return items
class ModelListAPIViewMixin(ListAPIViewMixinBase):
"""
Mixin that implements ListAPIViewMixin by loading items as models from DB.
"""
# Override in subclass
model_class = None
id_field = None
def load_items(self):
"""
Overriden from ListAPIViewMixinBase
"""
return self._group_by_id(
self.model_class.objects.filter(
**{self.id_field + '__in': self.ids}
)
)
@classmethod
def load_all_items(cls):
"""
Overriden from ListAPIViewMixinBase
"""
return cls._group_by_id(cls.model_class.objects.all())
@classmethod
def _group_by_id(cls, models):
model_groups = groupby(
models,
lambda model: getattr(model, cls.id_field),
)
return {
# We have to use a list comprehension to turn
# grouper objects into lists...
model_id: [model for model in model_grouper]
for model_id, model_grouper in model_groups
}
# Future TODO: figure out a way to make pylint not complain about
# no self arguments in @classproperty methods.
# pylint: disable=no-self-argument
class CachedListAPIViewMixin(ListAPIViewMixinBase):
"""
Mixin that adds caching functionality to a view.
"""
# Override in subclass
cache_root_prefix = None
data_version = None
# Optionally override in subclass
cache_name = 'default'
enable_caching = False
def load_items(self):
"""
Overriden from ListAPIViewMixinBase.
"""
return (
self._load_cached_items(item_ids=self.ids)
if self.enable_caching
else super(CachedListAPIViewMixin, self).load_items()
)
@classmethod
def load_all_items(cls):
"""
Overriden from ListAPIViewMixinBase.
"""
return (
cls._load_cached_items(item_ids=None)
if cls.enable_caching
else super(CachedListAPIViewMixin, cls).load_all_items()
)
@classmethod
def _load_cached_items(cls, item_ids=None):
"""
Try to load items from cache. On failure, fill cache and return items.
"""
if cls._is_cache_valid():
item_ids = item_ids or cls.cache.get(cls.cache_item_ids_key)
if item_ids:
item_keys_to_load = frozenset(cls.cache_item_key(item_id) for item_id in item_ids)
items = cls.cache.get_many(item_keys_to_load)
if item_keys_to_load == frozenset(items.keys()):
return items
all_items_by_id = cls.fill_cache()
return (
{
item_id: all_items_by_id[item_id]
for item_id in item_ids
if item_id in all_items_by_id
}
if item_ids
else all_items_by_id
)
@classmethod
def _is_cache_valid(cls):
cached_data_version = cls.cache.get(cls.cache_data_version_key)
cached_timestamp = cls.cache.get(cls.cache_timestamp_key)
return (
cached_data_version == cls.data_version and
cached_timestamp >= cls.source_data_timestamp()
)
@classmethod
def source_data_timestamp(cls):
"""
Get a datetime to store upon filling the cache so the new data can invalidate it.
Returns: datetime
"""
raise NotImplementedError('source_data_timestamp not overriden in subclass')
@classmethod
def fill_cache(cls):
all_items_by_id = super(CachedListAPIViewMixin, cls).load_all_items()
cls.cache.set(cls.cache_data_version_key, cls.data_version, None)
cls.cache.set(cls.cache_timestamp_key, cls.source_data_timestamp(), None)
cls.cache.set(cls.cache_item_ids_key, all_items_by_id.keys(), None)
all_items_by_key = {
cls.cache_item_key(item_id): item
for item_id, item in all_items_by_id.iteritems()
}
cls.cache.set_many(all_items_by_key, None)
return all_items_by_id
@classproperty
def cache(cls):
"""
Get cache to use. By default, uses caches[cls.cache_name]
"""
return caches[cls.cache_name]
@classproperty
def cache_data_version_key(cls):
"""
Get the cache key under which the data version is stored.
"""
return cls.cache_root_prefix + 'data-version'
@classproperty
def cache_timestamp_key(cls):
"""
Get the cache key under which the timestamp is stored.
"""
return cls.cache_root_prefix + 'timestamp'
@classproperty
def cache_item_ids_key(cls):
"""
Get the cache key under which the item ID list is stored.
"""
return cls.cache_root_prefix + 'item-ids'
@classmethod
def cache_item_key(cls, item_id):
"""
Get the cache key under which an item is stored, given its ID.
"""
return cls.cache_root_prefix + 'items/' + str(item_id)
class AggregatedListAPIViewMixin(ListAPIViewMixinBase):
"""
Mixin that aggregates loaded items by their IDs.
"""
# Optionally override in subclass
basic_aggregate_fields = frozenset()
calculated_aggregate_fields = {}
def load_items(self):
"""
Overrides ListAPIViewMixinBase.
"""
raw_items = super(AggregatedListAPIViewMixin, self).load_items()
return self.aggregate(raw_items)
@classmethod
def load_all_items(cls):
"""
Overrides ListAPIViewMixinBase.
"""
raw_items = super(AggregatedListAPIViewMixin, cls).load_all_items()
return cls.aggregate(raw_items)
@classmethod
def aggregate(cls, raw_item_groups):
"""
Return results aggregated by a distinct ID.
"""
return {
item_id: cls.aggregate_item_group(item_id, raw_item_group)
for item_id, raw_item_group in raw_item_groups.iteritems()
}
@classmethod
def aggregate_item_group(cls, item_id, raw_item_group):
"""
Aggregate a group of items. Optionally override in subclass.
Arguments:
item_id (str)
raw_item_group (list[T]), where T is item type
Returns: U, where U is the aggregate type
"""
def _apply_or_default(func, val, default):
return func(val) if val else default
base = {
cls.id_field: item_id
}
basic = {
field_name: (
getattr(raw_item_group[0], field_name, None)
if raw_item_group else None
)
for field_name in cls.basic_aggregate_fields
}
calculated = {
dst_field_name: _apply_or_default(
func,
(
getattr(raw_item, src_field_name)
for raw_item in raw_item_group
if hasattr(raw_item, src_field_name)
),
default,
)
for dst_field_name, (func, src_field_name, default)
in cls.calculated_aggregate_fields.iteritems()
}
return join_dicts(base, basic, calculated)
# An ad-hoc struct for policies on how to sort
# in SortedListAPIViewMixin
SortPolicy = namedtuple('SortPolicy', 'field default')
SortPolicy.__new__.__defaults__ = (None, None)
# pylint: disable=abstract-method
class SortedListAPIViewMixin(ListAPIViewMixinBase):
"""
Mixin that adds sorting functionality to a view.
"""
# Optionally override in subclass
sort_key_param = 'order_by'
sort_order_param = 'sort_order'
sort_policies = {}
def collect_params(self):
"""
Overriden from TypedQueryParametersAPIViewMixin.
"""
self.sort_key = self.get_query_param(
self.sort_key_param,
str,
self.sort_policies.keys()
)
self.sort_order = self.get_query_param(
self.sort_order_param,
str,
frozenset(['asc', 'desc']),
)
super(SortedListAPIViewMixin, self).collect_params()
def process_items(self, items):
"""
Overriden from ListAPIViewMixinBase.
"""
reverse = (self.sort_order == 'desc')
return super(SortedListAPIViewMixin, self).process_items(
OrderedDict(
sorted(items.iteritems(), key=self._get_sort_value, reverse=reverse)
if self.sort_key
else items
)
)
def _get_sort_value(self, item_with_id):
"""
Given an item, return the key by which it'll be sorted.
Arguments:
item_with_id ((str, T)), where T is the item type
Returns: U, where U is the sort key type
"""
sort_policy = self.sort_policies[self.sort_key]
value = item_with_id[1].get(
sort_policy.field or self.sort_key
) or sort_policy.default
return sort_policy.default if value is None else value
# Ad-hoc struct for policies on how to filter
# in FilteredListAPIViewMixin
FilterPolicy = namedtuple('FilterPolicy', 'field values value_map')
FilterPolicy.__new__.__defaults__ = (None, None, None)
# pylint: disable=abstract-method
class FilteredListAPIViewMixin(ListAPIViewMixinBase):
"""
Mixin that adds filtering functionality to a view.
"""
# Optionally override in subclass
filter_policies = {}
def collect_params(self):
"""
Overriden from TypedQueryParametersAPIViewMixin.
"""
param_filter_values = {
param_name: (policy, self.get_query_param(
param_name,
frozenset,
policy.value_map.keys() if policy.value_map else policy.values
))
for param_name, policy in self.filter_policies.iteritems()
if self.has_query_param(param_name)
}
self.filters = {
policy.field or param_name: (
frozenset.union(*(
policy.value_map[value] for value in values
))
if policy.value_map
else values
)
for param_name, (policy, values) in param_filter_values.iteritems()
}
super(FilteredListAPIViewMixin, self).collect_params()
def process_items(self, items):
"""
Overriden from ListAPIViewMixinBase.
"""
return super(FilteredListAPIViewMixin, self).process_items(
OrderedDict(
(item_id, item)
for item_id, item in items.iteritems()
if self._keep_item(item)
)
if self.filters
else items
)
def _keep_item(self, item):
"""
Returns whether or not an item should be kept, as opposed to filtered out.
"""
for field_name, allowed_values in self.filters.iteritems():
value = _get_field(item, field_name, None)
if isinstance(value, (frozenset, set, list)):
if not bool(frozenset(value) & allowed_values):
return False
else:
if value not in allowed_values:
return False
return True
# pylint: disable=abstract-method
class SearchedListAPIViewMixin(ListAPIViewMixinBase):
"""
Mixin that adds searching functionality to a view.
"""
# Override in subclass
search_param = None
search_fields = frozenset()
def collect_params(self):
"""
Overriden from TypedQueryParametersAPIViewMixin.
"""
search = self.get_query_param(self.search_param, str)
self.search = search.lower() if search else None
super(SearchedListAPIViewMixin, self).collect_params()
def process_items(self, items):
"""
Overriden from ListAPIViewMixinBase.
"""
return super(SearchedListAPIViewMixin, self).process_items(
OrderedDict(
(item_id, item)
for item_id, item in items.iteritems()
if self._matches_search(item)
)
if self.search
else items
)
def _matches_search(self, item):
for search_field in self.search_fields:
# pylint: disable=superfluous-parens
if self.search in (_get_field(item, search_field, '') or '').lower():
return True
return False
from django.utils import timezone
from rest_framework.generics import ListAPIView
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.utils import join_dicts
from analytics_data_api.v1 import models, serializers
from analytics_data_api.v1.views.base import (
AggregatedListAPIViewMixin,
CachedListAPIViewMixin,
DynamicFieldsAPIViewMixin,
FilteredListAPIViewMixin,
FilterPolicy,
ModelListAPIViewMixin,
PostAsGetAPIViewMixin,
SearchedListAPIViewMixin,
SortedListAPIViewMixin,
SortPolicy,
)
from analytics_data_api.v1.views.pagination import PostAsGetPaginationBase
from analytics_data_api.v1.views.utils import validate_course_id
class CourseSummariesPagination(PostAsGetPaginationBase):
page_size = 100
max_page_size = None
class CourseSummariesView(
CachedListAPIViewMixin,
AggregatedListAPIViewMixin,
ModelListAPIViewMixin,
FilteredListAPIViewMixin,
SearchedListAPIViewMixin,
SortedListAPIViewMixin,
DynamicFieldsAPIViewMixin,
PostAsGetAPIViewMixin,
ListAPIView,
):
"""
Returns summary information for courses.
**Example Requests**
```
GET /api/v1/course_summaries/?course_ids={course_id_1},{course_id_2}
&order_by=catalog_course_title
&sort_order=desc
&availability=Archived,Upcoming
&program_ids={program_id_1},{program_id_2}
&text_search=harvardx
&page=3
&page_size=50
POST /api/v1/course_summaries/
{
"course_ids": [
"{course_id_1}",
"{course_id_2}",
...
"{course_id_200}"
],
"order_by": "catalog_course_title",
"sort_order": "desc",
"availability": ["Archived", "Upcoming"],
"program_ids": ["{program_id_1}", "{program_id_2}"}],
"text_search": "harvardx",
"page": 3,
"page_size": 50
}
```
**Response Values**
Returns enrollment counts and other metadata for each course:
* course_id: The ID of the course for which data is returned.
* catalog_course_title: The name of the course.
* catalog_course: Course identifier without run.
* start_date: The date and time that the course begins
* end_date: The date and time that the course ends
* pacing_type: The type of pacing for this course
* availability: Availability status of the course
* count: The total count of currently enrolled learners across modes.
* cumulative_count: The total cumulative total of all users ever enrolled across modes.
* count_change_7_days: Total difference in enrollment counts over the past 7 days across modes.
* enrollment_modes: For each enrollment mode, the count, cumulative_count, and count_change_7_days.
* created: The date the counts were computed.
* programs: List of program IDs that this course is a part of.
**Parameters**
Results can be filtered, sorted, and paginated. Also, specific fields can be
included or excluded. All parameters are optional EXCEPT page.
For GET requests:
* Arguments are passed in the query string.
* List values are passed in as comma-delimited strings.
For POST requests:
* Arguments are passed in as a JSON dict in the request body.
* List values are passed as JSON arrays of strings.
* order_by -- The column to sort by. One of the following:
* catalog_course_title: The course title.
* start_date: The course's start datetime.
* end_date: The course's end datetime.
* cumulative_count: Total number of enrollments.
* count: Number of current enrollments.
* count_change_7_days: Change in current enrollments in past week
* verified_enrollment: Number of current verified enrollments.
* passing_users: Number of users who are passing
(Defaults to catalog_course_title)
* sort_order -- Order of the sort. One of the following:
* asc
* desc
(Defaults to asc)
* course_ids -- List of IDs of courses to filter by.
(Defaults to all courses)
* availability -- List of availabilities to filter by. List containing
one or more of the following:
* Archived
* Current
* Upcoming
* Unknown
(Defaults to all availabilities)
* program_ids -- List of IDs of programs to filter by.
(Defaults to all programs)
* text_search -- Sub-string to search for in course titles and IDs.
(Defaults to no search filtering)
* page (REQUIRED) -- Page number.
* page_size -- Size of page. Must be in range [1, 100]
(Defaults to 100)
* fields -- Fields of course summaries to return in response. Mutually
exclusive with `exclude` parameter.
(Defaults to including all fields)
* exclude -- Fields of course summaries to NOT return in response.
Mutually exclusive with `fields` parameter.
(Defaults to exluding no fields)
**Notes**
* GET is usable when the number of course IDs is relatively low.
* POST is required when the number of course IDs would cause the URL to
be too long.
* POST functions semantically as GET for this endpoint. It does not
modify any state.
"""
_COUNT_FIELDS = frozenset([
'count',
'cumulative_count',
'count_change_7_days',
'passing_users',
])
_TZ = timezone.get_default_timezone()
_MIN_DATETIME = timezone.make_aware(timezone.datetime.min, _TZ)
_MAX_DATETIME = timezone.make_aware(timezone.datetime.max, _TZ)
# From IDsAPIViewMixin
id_field = 'course_id'
ids_param = id_field + 's'
# From ListAPIView
serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer
pagination_class = CourseSummariesPagination
# From ModelListAPIViewMixin
model_class = models.CourseMetaSummaryEnrollment
# From AggregatedListAPIViewMixin
basic_aggregate_fields = frozenset([
'catalog_course_title',
'catalog_course',
'start_time',
'end_time',
'pacing_type',
'availability'
])
calculated_aggregate_fields = join_dicts(
{
'created': (max, 'created', None),
},
{
count_field: (sum, count_field, 0)
for count_field in _COUNT_FIELDS
}
)
# From CachedListAPIViewMixin
enable_caching = True
cache_name = 'summaries'
cache_root_prefix = 'course-summaries/'
data_version = 1
# From FilteredListAPIViewMixin
filter_policies = {
'availability': FilterPolicy(
value_map={
'Archived': frozenset(['Archived']),
'Current': frozenset(['Current']),
'Upcoming': frozenset(['Upcoming']),
'unknown': frozenset(['unknown', None]),
}
),
'pacing_type': FilterPolicy(values=frozenset(['self_paced', 'instructor_paced'])),
'program_ids': FilterPolicy(field='programs'),
}
# From SearchListAPIViewMixin
search_param = 'text_search'
search_fields = frozenset(['catalog_course_title', 'course_id'])
# From SortedListAPIViewMixin
sort_policies = join_dicts(
{
'catalog_course_title': SortPolicy(default='zzzzzz'),
'start_date': SortPolicy(field='start_time', default=_MIN_DATETIME),
'end_date': SortPolicy(field='end_time', default=_MIN_DATETIME),
},
{
count_field: SortPolicy(default=0)
for count_field in _COUNT_FIELDS | frozenset(['verified_enrollment'])
}
)
@classmethod
def aggregate(cls, raw_items):
result = super(CourseSummariesView, cls).aggregate(raw_items)
# Add in programs
course_programs = models.CourseProgramMetadata.objects.all()
for course_program in course_programs:
result_item = result.get(course_program.course_id)
if not result_item:
continue
if 'programs' not in result_item:
result_item['programs'] = set()
result_item['programs'].add(
course_program.program_id
)
return result
@classmethod
def aggregate_item_group(cls, item_id, raw_item_group):
result = super(CourseSummariesView, cls).aggregate_item_group(
item_id,
raw_item_group,
)
# Add in enrollment modes
raw_items_by_enrollment_mode = {
raw_item.enrollment_mode: raw_item
for raw_item in raw_item_group
}
result['enrollment_modes'] = {
enrollment_mode: {
count_field: getattr(
raw_items_by_enrollment_mode.get(enrollment_mode),
count_field,
0,
)
for count_field in cls._COUNT_FIELDS
}
for enrollment_mode in enrollment_modes.ALL
}
# Merge non-verified-professional with professional
modes = result['enrollment_modes']
for count_field, prof_no_id_val in modes[enrollment_modes.PROFESSIONAL_NO_ID].iteritems():
modes[enrollment_modes.PROFESSIONAL][count_field] = (
(prof_no_id_val or 0) +
modes[enrollment_modes.PROFESSIONAL].get(count_field, 0)
)
del modes[enrollment_modes.PROFESSIONAL_NO_ID]
# AN-8236 replace "Starting Soon" to "Upcoming" availability to collapse
# the two into one value
if result['availability'] == 'Starting Soon':
result['availability'] = 'Upcoming'
# Add in verified_enrollment
verified = result['enrollment_modes'].get(enrollment_modes.VERIFIED)
result['verified_enrollment'] = verified.get('count', 0) if verified else 0
return result
@classmethod
def source_data_timestamp(cls):
all_models = cls.model_class.objects.all()
return (
all_models[0].created if all_models.count() > 0
else cls._MIN_DATETIME
)
@classmethod
def validate_id_formats(cls, ids):
if not ids:
return
for course_id in ids:
validate_course_id(course_id)
def process_items(self, items):
processed_items = super(CourseSummariesView, self).process_items(items)
if self.fields_to_exclude:
self._exclude_from_enrollment_modes(processed_items, self.fields_to_exclude)
return processed_items
@staticmethod
def _exclude_from_enrollment_modes(items, to_exclude):
for item in items.values():
if 'enrollment_modes' not in item:
continue
item['enrollment_modes'] = {
mode: {
count_field: count
for count_field, count in counts.iteritems()
if count_field not in to_exclude
}
for mode, counts in item['enrollment_modes'].iteritems()
}
from django.db.models import Sum
from rest_framework.generics import RetrieveAPIView
from analytics_data_api.v1.models import CourseMetaSummaryEnrollment
from analytics_data_api.v1.serializers import CourseTotalsSerializer
from analytics_data_api.v1.views.base import (
IDsAPIViewMixin,
PostAsGetAPIViewMixin,
)
class CourseTotalsView(PostAsGetAPIViewMixin, IDsAPIViewMixin, RetrieveAPIView):
"""
Returns totals of course enrollment statistics.
**Example Requests**
GET /api/v1/course_totals/?course_ids={course_id_1},{course_id_2}
POST /api/v1/course_totals/
{
"course_ids": [
"{course_id_1}",
"{course_id_2}",
...
"{course_id_200}"
]
}
```
**Parameters**
For GET requests:
* Arguments are passed in the query string.
* List values are passed in as comma-delimited strings.
For POST requests:
* Arguments are passed in as a JSON dict in the request body.
* List values are passed as JSON arrays of strings.
* course_ids -- List of course ID strings to derive totals from.
**Response Values**
Returns enrollment counts and other metadata for each course:
* count: Total number of learners currently enrolled in the specified courses.
* cumulative_count: Total number of learners ever enrolled in the specified courses.
* count_change_7_days: Total change in enrollment across specified courses.
* verified_enrollment: Total number of leaners currently enrolled as verified in specified courses.
"""
serializer_class = CourseTotalsSerializer
# From IDsAPIViewMixin
ids_param = 'course_ids'
def get_object(self):
queryset = CourseMetaSummaryEnrollment.objects.all()
if self.ids:
queryset = queryset.filter(course_id__in=self.ids)
data = queryset.aggregate(
count=Sum('count'),
cumulative_count=Sum('cumulative_count'),
count_change_7_days=Sum('count_change_7_days')
)
data.update(
queryset.filter(enrollment_mode='verified').aggregate(
verified_enrollment=Sum('count')
)
)
return data
......@@ -15,10 +15,10 @@ from opaque_keys.edx.keys import CourseKey
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.utils import dictfetchall, get_course_report_download_details
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.exceptions import ReportFileNotFoundError
from analytics_data_api.v1 import models, serializers
from analytics_data_api.v1.exceptions import ReportFileNotFoundError
from analytics_data_api.v0.views.utils import raise_404_if_none
from analytics_data_api.v1.views.utils import raise_404_if_none
class BaseCourseView(generics.ListAPIView):
......@@ -75,7 +75,7 @@ class CourseActivityWeeklyView(BaseCourseView):
**Example request**
GET /api/v0/courses/{course_id}/activity/
GET /api/v1/courses/{course_id}/activity/
**Response Values**
......@@ -183,7 +183,7 @@ class CourseActivityMostRecentWeekView(generics.RetrieveAPIView):
**Example request**
GET /api/v0/courses/{course_id}/recent_activity/
GET /api/v1/courses/{course_id}/recent_activity/
**Response Values**
......@@ -283,7 +283,7 @@ class CourseEnrollmentByBirthYearView(BaseCourseEnrollmentView):
**Example request**
GET /api/v0/courses/{course_id}/enrollment/birth_year/
GET /api/v1/courses/{course_id}/enrollment/birth_year/
**Response Values**
......@@ -323,7 +323,7 @@ class CourseEnrollmentByEducationView(BaseCourseEnrollmentView):
**Example request**
GET /api/v0/courses/{course_id}/enrollment/education/
GET /api/v1/courses/{course_id}/enrollment/education/
**Response Values**
......@@ -364,7 +364,7 @@ class CourseEnrollmentByGenderView(BaseCourseEnrollmentView):
**Example request**
GET /api/v0/courses/{course_id}/enrollment/gender/
GET /api/v1/courses/{course_id}/enrollment/gender/
**Response Values**
......@@ -431,7 +431,7 @@ class CourseEnrollmentView(BaseCourseEnrollmentView):
**Example request**
GET /api/v0/courses/{course_id}/enrollment/
GET /api/v1/courses/{course_id}/enrollment/
**Response Values**
......@@ -468,7 +468,7 @@ class CourseEnrollmentModeView(BaseCourseEnrollmentView):
**Example request**
GET /api/v0/courses/{course_id}/enrollment/mode/
GET /api/v1/courses/{course_id}/enrollment/mode/
**Response Values**
......@@ -548,7 +548,7 @@ class CourseEnrollmentByLocationView(BaseCourseEnrollmentView):
**Example request**
GET /api/v0/courses/{course_id}/enrollment/location/
GET /api/v1/courses/{course_id}/enrollment/location/
**Response Values**
......@@ -629,7 +629,7 @@ class ProblemsListView(BaseCourseView):
**Example request**
GET /api/v0/courses/{course_id}/problems/
GET /api/v1/courses/{course_id}/problems/
**Response Values**
......@@ -705,7 +705,7 @@ class ProblemsAndTagsListView(BaseCourseView):
**Example request**
GET /api/v0/courses/{course_id}/problems_and_tags/
GET /api/v1/courses/{course_id}/problems_and_tags/
**Response Values**
......@@ -755,7 +755,7 @@ class VideosListView(BaseCourseView):
**Example request**
GET /api/v0/courses/{course_id}/videos/
GET /api/v1/courses/{course_id}/videos/
**Response Values**
......@@ -786,7 +786,7 @@ class ReportDownloadView(APIView):
**Example request**
GET /api/v0/courses/{course_id}/reports/{report_name}/
GET /api/v1/courses/{course_id}/reports/{report_name}/
**Response Values**
......
......@@ -5,26 +5,30 @@ import logging
from rest_framework import generics, status
from analytics_data_api.v0.exceptions import (
from analytics_data_api.v1.exceptions import (
LearnerEngagementTimelineNotFoundError,
LearnerNotFoundError,
ParameterValueError,
)
from analytics_data_api.v0.models import (
from analytics_data_api.v1.models import (
ModuleEngagement,
ModuleEngagementMetricRanges,
RosterEntry,
RosterUpdate,
)
from analytics_data_api.v0.serializers import (
from analytics_data_api.v1.serializers import (
CourseLearnerMetadataSerializer,
EdxPaginationSerializer,
EngagementDaySerializer,
LastUpdatedSerializer,
LearnerSerializer,
)
from analytics_data_api.v0.views import CourseViewMixin, PaginatedHeadersMixin, CsvViewMixin
from analytics_data_api.v0.views.utils import split_query_argument
from analytics_data_api.v1.views.base import (
CourseViewMixin,
PaginatedHeadersMixin,
CsvViewMixin,
)
from analytics_data_api.v1.views.utils import split_query_argument
logger = logging.getLogger(__name__)
......@@ -50,7 +54,7 @@ class LearnerView(LastUpdateMixin, CourseViewMixin, generics.RetrieveAPIView):
**Example Request**
GET /api/v0/learners/{username}/?course_id={course_id}
GET /api/v1/learners/{username}/?course_id={course_id}
**Response Values**
......@@ -126,7 +130,7 @@ class LearnerListView(LastUpdateMixin, CourseViewMixin, PaginatedHeadersMixin, C
**Example Request**
GET /api/v0/learners/?course_id={course_id}
GET /api/v1/learners/?course_id={course_id}
**Response Values**
......@@ -305,7 +309,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView):
**Example Request**
GET /api/v0/engagement_timeline/{username}/?course_id={course_id}
GET /api/v1/engagement_timeline/{username}/?course_id={course_id}
**Response Values**
......@@ -362,7 +366,7 @@ class CourseLearnerMetadata(CourseViewMixin, generics.RetrieveAPIView):
**Example Request**
GET /api/v0/course_learner_metadata/{course_id}/
GET /api/v1/course_learner_metadata/{course_id}/
**Response Values**
......
from django.core.paginator import InvalidPage
from rest_framework.exceptions import NotFound
from rest_framework.pagination import PageNumberPagination
def _positive_int(integer_string, strict=False, cutoff=None):
"""
Cast a string to a strictly positive integer.
"""
ret = int(integer_string)
if ret < 0 or (ret == 0 and strict):
raise ValueError()
if cutoff:
return min(ret, cutoff)
return ret
class PostAsGetPaginationBase(PageNumberPagination):
page_size_query_param = 'page_size'
# Override in subclass
page_size = None
max_page_size = None
# pylint: disable=attribute-defined-outside-init
def paginate_queryset(self, queryset, request, view=None):
"""
Paginate a queryset if required, either returning a
page object, or `None` if pagination is not configured for this view.
"""
if request.method == 'GET':
return super(PostAsGetPaginationBase, self).paginate_queryset(
queryset,
request,
view=view
)
page_size = self.get_page_size(request)
if not page_size:
return None
paginator = self.django_paginator_class(queryset, page_size)
page_number = request.data.get(self.page_query_param, 1)
if page_number in self.last_page_strings:
page_number = paginator.num_pages
try:
self.page = paginator.page(page_number)
except InvalidPage as exc:
msg = self.invalid_page_message.format(
page_number=page_number, message=exc.message
)
raise NotFound(msg)
if paginator.num_pages > 1 and self.template is not None:
# The browsable API should display pagination controls.
self.display_page_controls = True
self.request = request
return list(self.page)
def get_page_size(self, request):
if request.method == 'GET':
if self._is_all_in_params(request.query_params):
return None
return super(PostAsGetPaginationBase, self).get_page_size(request)
if self._is_all_in_params(request.data):
return None
if self.page_size_query_param and self.page_size_query_param in request.data:
try:
return _positive_int(
request.data.get(self.page_size_query_param),
strict=True,
cutoff=self.max_page_size
)
except (KeyError, ValueError):
pass
return self.page_size
@staticmethod
def _is_all_in_params(params):
param = params.get('all')
return param and param.lower() == 'true'
......@@ -8,13 +8,13 @@ from itertools import groupby
from django.db import OperationalError
from rest_framework import generics
from analytics_data_api.v0.models import (
from analytics_data_api.v1.models import (
GradeDistribution,
ProblemResponseAnswerDistribution,
ProblemFirstLastResponseAnswerDistribution,
SequentialOpenDistribution,
)
from analytics_data_api.v0.serializers import (
from analytics_data_api.v1.serializers import (
ConsolidatedAnswerDistributionSerializer,
ConsolidatedFirstLastAnswerDistributionSerializer,
GradeDistributionSerializer,
......@@ -22,7 +22,7 @@ from analytics_data_api.v0.serializers import (
)
from analytics_data_api.utils import matching_tuple
from analytics_data_api.v0.views.utils import raise_404_if_none
from analytics_data_api.v1.views.utils import raise_404_if_none
class ProblemResponseAnswerDistributionView(generics.ListAPIView):
......@@ -31,7 +31,7 @@ class ProblemResponseAnswerDistributionView(generics.ListAPIView):
**Example request**
GET /api/v0/problems/{problem_id}/answer_distribution
GET /api/v1/problems/{problem_id}/answer_distribution
**Response Values**
......@@ -126,7 +126,7 @@ class GradeDistributionView(generics.ListAPIView):
**Example request**
GET /api/v0/problems/{problem_id}/grade_distribution
GET /api/v1/problems/{problem_id}/grade_distribution
**Response Values**
......@@ -158,7 +158,7 @@ class SequentialOpenDistributionView(generics.ListAPIView):
**Example request**
GET /api/v0/problems/{module_id}/sequential_open_distribution
GET /api/v1/problems/{module_id}/sequential_open_distribution
**Response Values**
......
from rest_framework.generics import ListAPIView
from analytics_data_api.v1 import models, serializers
from analytics_data_api.v1.views.base import (
AggregatedListAPIViewMixin,
DynamicFieldsAPIViewMixin,
ModelListAPIViewMixin,
)
class ProgramsView(
AggregatedListAPIViewMixin,
ModelListAPIViewMixin,
DynamicFieldsAPIViewMixin,
ListAPIView,
):
"""
Returns metadata information for programs.
**Example Request**
GET /api/v1/course_programs/?program_ids={program_id},{program_id}
**Response Values**
Returns metadata for every program:
* program_id: The ID of the program for which data is returned.
* program_type: The type of the program
* program_title: The title of the program
* created: The date the metadata was computed.
**Parameters**
Results can be filtered to the program IDs specified or limited to the fields.
program_ids -- The comma-separated program identifiers for which metadata is requested.
Default is to return all programs.
fields -- The comma-separated fields to return in the response.
For example, 'program_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
For example, 'program_id,created'. Default is to not exclude any fields.
"""
id_field = 'program_id'
# From ListAPIView
serializer_class = serializers.CourseProgramMetadataSerializer
# From ListAPIViewMixinBase
ids_param = id_field + 's'
# From ModelListAPIViewMixin
model_class = models.CourseProgramMetadata
model_id_field = id_field
# From AggregatedListAPIViewMixin
raw_item_id_field = id_field
aggregate_item_id_field = id_field
basic_aggregate_fields = frozenset(['program_title', 'program_type'])
calculated_aggregate_fields = {
'course_ids': (list, 'course_id', []),
'created': (max, 'created', None),
}
......@@ -4,7 +4,7 @@ from django.http import Http404
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.v0.exceptions import CourseKeyMalformedError
from analytics_data_api.v1.exceptions import CourseKeyMalformedError
def split_query_argument(argument):
......
......@@ -4,10 +4,10 @@ API methods for module level data.
from rest_framework import generics
from analytics_data_api.v0.models import VideoTimeline
from analytics_data_api.v0.serializers import VideoTimelineSerializer
from analytics_data_api.v1.models import VideoTimeline
from analytics_data_api.v1.serializers import VideoTimelineSerializer
from analytics_data_api.v0.views.utils import raise_404_if_none
from analytics_data_api.v1.views.utils import raise_404_if_none
class VideoTimelineView(generics.ListAPIView):
......@@ -16,7 +16,7 @@ class VideoTimelineView(generics.ListAPIView):
**Example Request**
GET /api/v0/videos/{video_id}/timeline/
GET /api/v1/videos/{video_id}/timeline/
**Response Values**
......
......@@ -7,7 +7,7 @@ class AnalyticsApiRouter(object):
return self._get_database(model._meta.app_label)
def _get_database(self, app_label):
if app_label == 'v0':
if app_label == 'v1':
return getattr(settings, 'ANALYTICS_DATABASE', 'default')
return None
......
......@@ -58,7 +58,7 @@ ELASTICSEARCH_LEARNERS_UPDATE_INDEX = environ.get('ELASTICSEARCH_LEARNERS_UPDATE
ELASTICSEARCH_AWS_ACCESS_KEY_ID = None
ELASTICSEARCH_AWS_SECRET_ACCESS_KEY = None
# override the default elasticsearch connection class and useful for signing certificates
# e.g. 'analytics_data_api.v0.connections.BotoHttpConnection'
# e.g. 'analytics_data_api.v1.connections.BotoHttpConnection'
ELASTICSEARCH_CONNECTION_CLASS = None
# only needed with BotoHttpConnection, e.g. 'us-east-1'
ELASTICSEARCH_CONNECTION_DEFAULT_REGION = None
......@@ -163,13 +163,13 @@ MIDDLEWARE_CLASSES = (
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'analytics_data_api.v0.middleware.LearnerEngagementTimelineNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.LearnerNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.CourseNotSpecifiedErrorMiddleware',
'analytics_data_api.v0.middleware.CourseKeyMalformedErrorMiddleware',
'analytics_data_api.v0.middleware.ParameterValueErrorMiddleware',
'analytics_data_api.v0.middleware.ReportFileNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.CannotCreateDownloadLinkErrorMiddleware',
'analytics_data_api.v1.middleware.LearnerEngagementTimelineNotFoundErrorMiddleware',
'analytics_data_api.v1.middleware.LearnerNotFoundErrorMiddleware',
'analytics_data_api.v1.middleware.CourseNotSpecifiedErrorMiddleware',
'analytics_data_api.v1.middleware.CourseKeyMalformedErrorMiddleware',
'analytics_data_api.v1.middleware.ParameterValueErrorMiddleware',
'analytics_data_api.v1.middleware.ReportFileNotFoundErrorMiddleware',
'analytics_data_api.v1.middleware.CannotCreateDownloadLinkErrorMiddleware',
)
########## END MIDDLEWARE CONFIGURATION
......@@ -202,7 +202,7 @@ THIRD_PARTY_APPS = (
LOCAL_APPS = (
'analytics_data_api',
'analytics_data_api.v0',
'analytics_data_api.v1',
)
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
......@@ -324,3 +324,15 @@ DATA_UPLOAD_MAX_NUMBER_FIELDS = None
DATE_FORMAT = '%Y-%m-%d'
DATETIME_FORMAT = '%Y-%m-%dT%H%M%S'
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
},
'summaries': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'OPTIONS': {
'MAX_ENTRIES': 100000
},
},
}
......@@ -40,17 +40,6 @@ DATABASES = {
}
########## END DATABASE CONFIGURATION
########## CACHE CONFIGURATION
# See: https://docs.djangoproject.com/en/dev/ref/settings/#caches
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
########## END CACHE CONFIGURATION
########## ANALYTICS DATA API CONFIGURATION
ANALYTICS_DATABASE = 'analytics'
......
......@@ -40,3 +40,7 @@ FTP_STORAGE_LOCATION = 'ftp://localhost:80/path'
# Default settings for report download endpoint
COURSE_REPORT_FILE_LOCATION_TEMPLATE = '/{course_id}_{report_name}.csv'
COURSE_REPORT_DOWNLOAD_EXPIRY_TIME = 120
CACHES['summaries'] = {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
}
......@@ -13,7 +13,7 @@ from django.test.utils import override_settings
from rest_framework.authtoken.models import Token
from requests.exceptions import ConnectionError
from analytics_data_api.v0.models import CourseEnrollmentDaily, CourseEnrollmentByBirthYear
from analytics_data_api.v1.models import CourseEnrollmentDaily, CourseEnrollmentByBirthYear
from analyticsdataserver.clients import CourseBlocksApiClient
from analyticsdataserver.router import AnalyticsApiRouter
from analyticsdataserver.utils import temp_log_level
......
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