From 7e5476c8d10927ff051e5a82d0e8d6278dae3dc0 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Tue, 11 Jul 2017 17:45:59 -0400 Subject: [PATCH] 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 --- .../constants/enrollment_modes.py | 2 +- .../problem_response_answer_distribution.json | 30 +- .../commands/generate_fake_course_data.py | 2 +- analytics_data_api/urls.py | 2 +- analytics_data_api/utils.py | 24 +- analytics_data_api/v0/__init__.py | 1 - .../v0/tests/views/test_course_summaries.py | 174 ---- analytics_data_api/v0/views/__init__.py | 267 ------ .../v0/views/course_summaries.py | 173 ---- analytics_data_api/v0/views/programs.py | 63 -- analytics_data_api/v1/__init__.py | 1 + analytics_data_api/{v0 => v1}/apps.py | 2 +- analytics_data_api/{v0 => v1}/connections.py | 0 analytics_data_api/{v0 => v1}/exceptions.py | 0 analytics_data_api/{v0 => v1}/middleware.py | 2 +- analytics_data_api/{v0 => v1}/models.py | 10 +- analytics_data_api/{v0 => v1}/serializers.py | 15 +- .../{v0 => v1}/tests/__init__.py | 0 .../{v0 => v1}/tests/test_connections.py | 6 +- .../{v0 => v1}/tests/test_models.py | 2 +- .../{v0 => v1}/tests/test_serializers.py | 2 +- .../{v0 => v1}/tests/test_urls.py | 2 +- analytics_data_api/{v0 => v1}/tests/utils.py | 0 .../{v0 => v1}/tests/views/__init__.py | 102 ++- .../v1/tests/views/test_course_summaries.py | 462 ++++++++++ .../v1/tests/views/test_course_totals.py | 89 ++ .../{v0 => v1}/tests/views/test_courses.py | 32 +- .../tests/views/test_engagement_timelines.py | 8 +- .../{v0 => v1}/tests/views/test_learners.py | 24 +- .../{v0 => v1}/tests/views/test_problems.py | 18 +- .../{v0 => v1}/tests/views/test_programs.py | 10 +- .../{v0 => v1}/tests/views/test_utils.py | 6 +- .../{v0 => v1}/tests/views/test_videos.py | 4 +- .../{v0 => v1}/urls/__init__.py | 14 +- .../{v0 => v1}/urls/course_summaries.py | 2 +- analytics_data_api/v1/urls/course_totals.py | 7 + analytics_data_api/{v0 => v1}/urls/courses.py | 4 +- .../{v0 => v1}/urls/learners.py | 4 +- .../{v0 => v1}/urls/problems.py | 2 +- .../{v0 => v1}/urls/programs.py | 2 +- analytics_data_api/{v0 => v1}/urls/videos.py | 2 +- analytics_data_api/v1/views/__init__.py | 0 analytics_data_api/v1/views/base.py | 787 ++++++++++++++++++ .../v1/views/course_summaries.py | 318 +++++++ analytics_data_api/v1/views/course_totals.py | 70 ++ .../{v0 => v1}/views/courses.py | 30 +- .../{v0 => v1}/views/learners.py | 22 +- analytics_data_api/v1/views/pagination.py | 86 ++ .../{v0 => v1}/views/problems.py | 12 +- analytics_data_api/v1/views/programs.py | 63 ++ analytics_data_api/{v0 => v1}/views/utils.py | 2 +- analytics_data_api/{v0 => v1}/views/videos.py | 8 +- analyticsdataserver/router.py | 2 +- analyticsdataserver/settings/base.py | 30 +- analyticsdataserver/settings/local.py | 11 - analyticsdataserver/settings/test.py | 4 + analyticsdataserver/tests.py | 2 +- 57 files changed, 2154 insertions(+), 865 deletions(-) delete mode 100644 analytics_data_api/v0/__init__.py delete mode 100644 analytics_data_api/v0/tests/views/test_course_summaries.py delete mode 100644 analytics_data_api/v0/views/__init__.py delete mode 100644 analytics_data_api/v0/views/course_summaries.py delete mode 100644 analytics_data_api/v0/views/programs.py create mode 100644 analytics_data_api/v1/__init__.py rename analytics_data_api/{v0 => v1}/apps.py (97%) rename analytics_data_api/{v0 => v1}/connections.py (100%) rename analytics_data_api/{v0 => v1}/exceptions.py (100%) rename analytics_data_api/{v0 => v1}/middleware.py (98%) rename analytics_data_api/{v0 => v1}/models.py (98%) rename analytics_data_api/{v0 => v1}/serializers.py (97%) rename analytics_data_api/{v0 => v1}/tests/__init__.py (100%) rename analytics_data_api/{v0 => v1}/tests/test_connections.py (93%) rename analytics_data_api/{v0 => v1}/tests/test_models.py (96%) rename analytics_data_api/{v0 => v1}/tests/test_serializers.py (94%) rename analytics_data_api/{v0 => v1}/tests/test_urls.py (95%) rename analytics_data_api/{v0 => v1}/tests/utils.py (100%) rename analytics_data_api/{v0 => v1}/tests/views/__init__.py (62%) create mode 100644 analytics_data_api/v1/tests/views/test_course_summaries.py create mode 100644 analytics_data_api/v1/tests/views/test_course_totals.py rename analytics_data_api/{v0 => v1}/tests/views/test_courses.py (97%) rename analytics_data_api/{v0 => v1}/tests/views/test_engagement_timelines.py (96%) rename analytics_data_api/{v0 => v1}/tests/views/test_learners.py (98%) rename analytics_data_api/{v0 => v1}/tests/views/test_problems.py (92%) rename analytics_data_api/{v0 => v1}/tests/views/test_programs.py (89%) rename analytics_data_api/{v0 => v1}/tests/views/test_utils.py (88%) rename analytics_data_api/{v0 => v1}/tests/views/test_videos.py (95%) rename analytics_data_api/{v0 => v1}/urls/__init__.py (58%) rename analytics_data_api/{v0 => v1}/urls/course_summaries.py (70%) create mode 100644 analytics_data_api/v1/urls/course_totals.py rename analytics_data_api/{v0 => v1}/urls/courses.py (91%) rename analytics_data_api/{v0 => v1}/urls/learners.py (83%) rename analytics_data_api/{v0 => v1}/urls/problems.py (90%) rename analytics_data_api/{v0 => v1}/urls/programs.py (68%) rename analytics_data_api/{v0 => v1}/urls/videos.py (83%) create mode 100644 analytics_data_api/v1/views/__init__.py create mode 100644 analytics_data_api/v1/views/base.py create mode 100644 analytics_data_api/v1/views/course_summaries.py create mode 100644 analytics_data_api/v1/views/course_totals.py rename analytics_data_api/{v0 => v1}/views/courses.py (97%) rename analytics_data_api/{v0 => v1}/views/learners.py (96%) create mode 100644 analytics_data_api/v1/views/pagination.py rename analytics_data_api/{v0 => v1}/views/problems.py (94%) create mode 100644 analytics_data_api/v1/views/programs.py rename analytics_data_api/{v0 => v1}/views/utils.py (93%) rename analytics_data_api/{v0 => v1}/views/videos.py (80%) diff --git a/analytics_data_api/constants/enrollment_modes.py b/analytics_data_api/constants/enrollment_modes.py index 5d7c4347..1ea62ca9 100644 --- a/analytics_data_api/constants/enrollment_modes.py +++ b/analytics_data_api/constants/enrollment_modes.py @@ -5,4 +5,4 @@ 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]) diff --git a/analytics_data_api/fixtures/problem_response_answer_distribution.json b/analytics_data_api/fixtures/problem_response_answer_distribution.json index c693e74c..9d1d9217 100644 --- a/analytics_data_api/fixtures/problem_response_answer_distribution.json +++ b/analytics_data_api/fixtures/problem_response_answer_distribution.json @@ -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 } diff --git a/analytics_data_api/management/commands/generate_fake_course_data.py b/analytics_data_api/management/commands/generate_fake_course_data.py index 9f5b82f6..c2c092e6 100644 --- a/analytics_data_api/management/commands/generate_fake_course_data.py +++ b/analytics_data_api/management/commands/generate_fake_course_data.py @@ -12,7 +12,7 @@ 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) diff --git a/analytics_data_api/urls.py b/analytics_data_api/urls.py index 15bf51b1..d8d2c8f2 100644 --- a/analytics_data_api/urls.py +++ b/analytics_data_api/urls.py @@ -2,7 +2,7 @@ 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) diff --git a/analytics_data_api/utils.py b/analytics_data_api/utils.py index 24b7886d..682f37f1 100644 --- a/analytics_data_api/utils.py +++ b/analytics_data_api/utils.py @@ -10,7 +10,7 @@ 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 diff --git a/analytics_data_api/v0/__init__.py b/analytics_data_api/v0/__init__.py deleted file mode 100644 index ce955d0d..00000000 --- a/analytics_data_api/v0/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'analytics_data_api.v0.apps.ApiAppConfig' diff --git a/analytics_data_api/v0/tests/views/test_course_summaries.py b/analytics_data_api/v0/tests/views/test_course_summaries.py deleted file mode 100644 index 37b01c63..00000000 --- a/analytics_data_api/v0/tests/views/test_course_summaries.py +++ /dev/null @@ -1,174 +0,0 @@ -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) diff --git a/analytics_data_api/v0/views/__init__.py b/analytics_data_api/v0/views/__init__.py deleted file mode 100644 index 652097c4..00000000 --- a/analytics_data_api/v0/views/__init__.py +++ /dev/null @@ -1,267 +0,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. - ; rel="next", ; 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 diff --git a/analytics_data_api/v0/views/course_summaries.py b/analytics_data_api/v0/views/course_summaries.py deleted file mode 100644 index 431113e5..00000000 --- a/analytics_data_api/v0/views/course_summaries.py +++ /dev/null @@ -1,173 +0,0 @@ -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()) diff --git a/analytics_data_api/v0/views/programs.py b/analytics_data_api/v0/views/programs.py deleted file mode 100644 index 3e44ec72..00000000 --- a/analytics_data_api/v0/views/programs.py +++ /dev/null @@ -1,63 +0,0 @@ -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()) diff --git a/analytics_data_api/v1/__init__.py b/analytics_data_api/v1/__init__.py new file mode 100644 index 00000000..2b6aee62 --- /dev/null +++ b/analytics_data_api/v1/__init__.py @@ -0,0 +1 @@ +default_app_config = 'analytics_data_api.v1.apps.ApiAppConfig' diff --git a/analytics_data_api/v0/apps.py b/analytics_data_api/v1/apps.py similarity index 97% rename from analytics_data_api/v0/apps.py rename to analytics_data_api/v1/apps.py index 4e7f6fdb..5757302c 100644 --- a/analytics_data_api/v0/apps.py +++ b/analytics_data_api/v1/apps.py @@ -5,7 +5,7 @@ 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 diff --git a/analytics_data_api/v0/connections.py b/analytics_data_api/v1/connections.py similarity index 100% rename from analytics_data_api/v0/connections.py rename to analytics_data_api/v1/connections.py diff --git a/analytics_data_api/v0/exceptions.py b/analytics_data_api/v1/exceptions.py similarity index 100% rename from analytics_data_api/v0/exceptions.py rename to analytics_data_api/v1/exceptions.py diff --git a/analytics_data_api/v0/middleware.py b/analytics_data_api/v1/middleware.py similarity index 98% rename from analytics_data_api/v0/middleware.py rename to analytics_data_api/v1/middleware.py index b1ce0b12..7b6916a1 100644 --- a/analytics_data_api/v0/middleware.py +++ b/analytics_data_api/v1/middleware.py @@ -2,7 +2,7 @@ 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, diff --git a/analytics_data_api/v0/models.py b/analytics_data_api/v1/models.py similarity index 98% rename from analytics_data_api/v0/models.py rename to analytics_data_api/v1/models.py index 9046b5e1..e5b88651 100644 --- a/analytics_data_api/v0/models.py +++ b/analytics_data_api/v1/models.py @@ -68,12 +68,12 @@ class Meta(BaseCourseEnrollment.Meta): 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) diff --git a/analytics_data_api/v0/serializers.py b/analytics_data_api/v1/serializers.py similarity index 97% rename from analytics_data_api/v0/serializers.py rename to analytics_data_api/v1/serializers.py index 55101733..421966c2 100644 --- a/analytics_data_api/v0/serializers.py +++ b/analytics_data_api/v1/serializers.py @@ -8,7 +8,7 @@ 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 @@ def get_enrollment_modes(self, obj): 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 Meta(object): # 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() diff --git a/analytics_data_api/v0/tests/__init__.py b/analytics_data_api/v1/tests/__init__.py similarity index 100% rename from analytics_data_api/v0/tests/__init__.py rename to analytics_data_api/v1/tests/__init__.py diff --git a/analytics_data_api/v0/tests/test_connections.py b/analytics_data_api/v1/tests/test_connections.py similarity index 93% rename from analytics_data_api/v0/tests/test_connections.py rename to analytics_data_api/v1/tests/test_connections.py index d4eb43d4..a3970716 100644 --- a/analytics_data_api/v0/tests/test_connections.py +++ b/analytics_data_api/v1/tests/test_connections.py @@ -4,7 +4,7 @@ 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 @@ def fake_connection(*args): # pylint: disable=unused-argument 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 @@ def test_perform_request_success(self, mock_response): 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') diff --git a/analytics_data_api/v0/tests/test_models.py b/analytics_data_api/v1/tests/test_models.py similarity index 96% rename from analytics_data_api/v0/tests/test_models.py rename to analytics_data_api/v1/tests/test_models.py index 8806d8c5..2eefcc81 100644 --- a/analytics_data_api/v0/tests/test_models.py +++ b/analytics_data_api/v1/tests/test_models.py @@ -1,7 +1,7 @@ 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 diff --git a/analytics_data_api/v0/tests/test_serializers.py b/analytics_data_api/v1/tests/test_serializers.py similarity index 94% rename from analytics_data_api/v0/tests/test_serializers.py rename to analytics_data_api/v1/tests/test_serializers.py index 42028dee..de86341c 100644 --- a/analytics_data_api/v0/tests/test_serializers.py +++ b/analytics_data_api/v1/tests/test_serializers.py @@ -2,7 +2,7 @@ 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): diff --git a/analytics_data_api/v0/tests/test_urls.py b/analytics_data_api/v1/tests/test_urls.py similarity index 95% rename from analytics_data_api/v0/tests/test_urls.py rename to analytics_data_api/v1/tests/test_urls.py index f2f7d700..6ff831c0 100644 --- a/analytics_data_api/v0/tests/test_urls.py +++ b/analytics_data_api/v1/tests/test_urls.py @@ -3,7 +3,7 @@ class UrlRedirectTests(TestCase): - api_root_path = '/api/v0/' + api_root_path = '/api/v1/' def assertRedirectsToRootPath(self, path, **kwargs): assert_kwargs = {'status_code': 302} diff --git a/analytics_data_api/v0/tests/utils.py b/analytics_data_api/v1/tests/utils.py similarity index 100% rename from analytics_data_api/v0/tests/utils.py rename to analytics_data_api/v1/tests/utils.py diff --git a/analytics_data_api/v0/tests/views/__init__.py b/analytics_data_api/v1/tests/views/__init__.py similarity index 62% rename from analytics_data_api/v0/tests/views/__init__.py rename to analytics_data_api/v1/tests/views/__init__.py index 16f70b7e..5d9910ad 100644 --- a/analytics_data_api/v0/tests/views/__init__.py +++ b/analytics_data_api/v1/tests/views/__init__.py @@ -7,7 +7,7 @@ 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 @@ def all_expected_results(self, ids=None, **kwargs): 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 @@ def _test_fields(self, fields): 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 diff --git a/analytics_data_api/v1/tests/views/test_course_summaries.py b/analytics_data_api/v1/tests/views/test_course_summaries.py new file mode 100644 index 00000000..7d9074bf --- /dev/null +++ b/analytics_data_api/v1/tests/views/test_course_summaries.py @@ -0,0 +1,462 @@ +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) diff --git a/analytics_data_api/v1/tests/views/test_course_totals.py b/analytics_data_api/v1/tests/views/test_course_totals.py new file mode 100644 index 00000000..6709796a --- /dev/null +++ b/analytics_data_api/v1/tests/views/test_course_totals.py @@ -0,0 +1,89 @@ +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) diff --git a/analytics_data_api/v0/tests/views/test_courses.py b/analytics_data_api/v1/tests/views/test_courses.py similarity index 97% rename from analytics_data_api/v0/tests/views/test_courses.py rename to analytics_data_api/v1/tests/views/test_courses.py index bc2093a3..1ffd0f6a 100644 --- a/analytics_data_api/v0/tests/views/test_courses.py +++ b/analytics_data_api/v1/tests/views/test_courses.py @@ -17,8 +17,8 @@ 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 @@ def test_default_fill(self, course_id): @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 @@ def generate_data(self, course_id): @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 @@ def get_activity_record(**kwargs): @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 @@ def test_video_activity(self, course_id): 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 @@ def format_as_response(self, *args): @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 @@ def _get_data(self, course_id): """ 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 @@ def _get_data(self, course_id): """ 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 @@ def _get_data(self, course_id): """ 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 @@ def test_get_404(self): @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) diff --git a/analytics_data_api/v0/tests/views/test_engagement_timelines.py b/analytics_data_api/v1/tests/views/test_engagement_timelines.py similarity index 96% rename from analytics_data_api/v0/tests/views/test_engagement_timelines.py rename to analytics_data_api/v1/tests/views/test_engagement_timelines.py index 50618e2f..5262279c 100644 --- a/analytics_data_api/v0/tests/views/test_engagement_timelines.py +++ b/analytics_data_api/v1/tests/views/test_engagement_timelines.py @@ -11,14 +11,14 @@ 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 @@ def test_not_found(self, course_id): 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) diff --git a/analytics_data_api/v0/tests/views/test_learners.py b/analytics_data_api/v1/tests/views/test_learners.py similarity index 98% rename from analytics_data_api/v0/tests/views/test_learners.py rename to analytics_data_api/v1/tests/views/test_learners.py index a11d1176..3c8d3f07 100644 --- a/analytics_data_api/v0/tests/views/test_learners.py +++ b/analytics_data_api/v1/tests/views/test_learners.py @@ -18,9 +18,9 @@ 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 @@ def expected_page_url(self, course_id, page, page_size): 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 @@ def expected_page_url(self, course_id, page, page_size): @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 @@ def test_get_user(self, username, name, course_id, enrollment_mode, segments=Non } 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 @@ def test_not_found(self): 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 @@ def setUp(self): 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 @@ def test_pagination(self): ) @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 @@ def setUp(self): 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 @@ def assert_response_matches(self, response, expected_status_code, expected_data) 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( diff --git a/analytics_data_api/v0/tests/views/test_problems.py b/analytics_data_api/v1/tests/views/test_problems.py similarity index 92% rename from analytics_data_api/v0/tests/views/test_problems.py rename to analytics_data_api/v1/tests/views/test_problems.py index 699b0176..79029c13 100644 --- a/analytics_data_api/v0/tests/views/test_problems.py +++ b/analytics_data_api/v1/tests/views/test_problems.py @@ -9,8 +9,8 @@ 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 @@ def setUpClass(cls): 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 @@ def test_nonconsolidated_get(self): 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 @@ def test_consolidated_get(self): 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 @@ def setUpClass(cls): ) 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 @@ def test_get(self): 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 @@ def setUpClass(cls): ) 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 @@ def test_get(self): 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) diff --git a/analytics_data_api/v0/tests/views/test_programs.py b/analytics_data_api/v1/tests/views/test_programs.py similarity index 89% rename from analytics_data_api/v0/tests/views/test_programs.py rename to analytics_data_api/v1/tests/views/test_programs.py index 4df335be..822e5505 100644 --- a/analytics_data_api/v0/tests/views/test_programs.py +++ b/analytics_data_api/v1/tests/views/test_programs.py @@ -2,8 +2,8 @@ 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 @@ def test_fields(self, fields): @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) diff --git a/analytics_data_api/v0/tests/views/test_utils.py b/analytics_data_api/v1/tests/views/test_utils.py similarity index 88% rename from analytics_data_api/v0/tests/views/test_utils.py rename to analytics_data_api/v1/tests/views/test_utils.py index 0fa7a39f..f5f146c6 100644 --- a/analytics_data_api/v0/tests/views/test_utils.py +++ b/analytics_data_api/v1/tests/views/test_utils.py @@ -4,9 +4,9 @@ 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 diff --git a/analytics_data_api/v0/tests/views/test_videos.py b/analytics_data_api/v1/tests/views/test_videos.py similarity index 95% rename from analytics_data_api/v0/tests/views/test_videos.py rename to analytics_data_api/v1/tests/views/test_videos.py index eb08ba99..b865e09a 100644 --- a/analytics_data_api/v0/tests/views/test_videos.py +++ b/analytics_data_api/v1/tests/views/test_videos.py @@ -4,14 +4,14 @@ 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 diff --git a/analytics_data_api/v0/urls/__init__.py b/analytics_data_api/v1/urls/__init__.py similarity index 58% rename from analytics_data_api/v0/urls/__init__.py rename to analytics_data_api/v1/urls/__init__.py index ba4de848..ec54ad11 100644 --- a/analytics_data_api/v0/urls/__init__.py +++ b/analytics_data_api/v1/urls/__init__.py @@ -2,15 +2,17 @@ from django.core.urlresolvers import reverse_lazy from django.views.generic import RedirectView + COURSE_ID_PATTERN = r'(?P[^/+]+[/+][^/+]+[/+][^/]+)' 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'), diff --git a/analytics_data_api/v0/urls/course_summaries.py b/analytics_data_api/v1/urls/course_summaries.py similarity index 70% rename from analytics_data_api/v0/urls/course_summaries.py rename to analytics_data_api/v1/urls/course_summaries.py index a03bb27b..c79be4dd 100644 --- a/analytics_data_api/v0/urls/course_summaries.py +++ b/analytics_data_api/v1/urls/course_summaries.py @@ -1,6 +1,6 @@ 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'), diff --git a/analytics_data_api/v1/urls/course_totals.py b/analytics_data_api/v1/urls/course_totals.py new file mode 100644 index 00000000..6b3e20df --- /dev/null +++ b/analytics_data_api/v1/urls/course_totals.py @@ -0,0 +1,7 @@ +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'), +] diff --git a/analytics_data_api/v0/urls/courses.py b/analytics_data_api/v1/urls/courses.py similarity index 91% rename from analytics_data_api/v0/urls/courses.py rename to analytics_data_api/v1/urls/courses.py index 463e912d..c88ce6a9 100644 --- a/analytics_data_api/v0/urls/courses.py +++ b/analytics_data_api/v1/urls/courses.py @@ -1,7 +1,7 @@ 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'), diff --git a/analytics_data_api/v0/urls/learners.py b/analytics_data_api/v1/urls/learners.py similarity index 83% rename from analytics_data_api/v0/urls/learners.py rename to analytics_data_api/v1/urls/learners.py index f718cdce..e97976be 100644 --- a/analytics_data_api/v0/urls/learners.py +++ b/analytics_data_api/v1/urls/learners.py @@ -1,7 +1,7 @@ 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[\w.+-]+)' diff --git a/analytics_data_api/v0/urls/problems.py b/analytics_data_api/v1/urls/problems.py similarity index 90% rename from analytics_data_api/v0/urls/problems.py rename to analytics_data_api/v1/urls/problems.py index edfac7c1..4ba35a18 100644 --- a/analytics_data_api/v0/urls/problems.py +++ b/analytics_data_api/v1/urls/problems.py @@ -2,7 +2,7 @@ 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'), diff --git a/analytics_data_api/v0/urls/programs.py b/analytics_data_api/v1/urls/programs.py similarity index 68% rename from analytics_data_api/v0/urls/programs.py rename to analytics_data_api/v1/urls/programs.py index 1254ac87..f52ca97d 100644 --- a/analytics_data_api/v0/urls/programs.py +++ b/analytics_data_api/v1/urls/programs.py @@ -1,6 +1,6 @@ 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'), diff --git a/analytics_data_api/v0/urls/videos.py b/analytics_data_api/v1/urls/videos.py similarity index 83% rename from analytics_data_api/v0/urls/videos.py rename to analytics_data_api/v1/urls/videos.py index 466eb7e9..3e13dd48 100644 --- a/analytics_data_api/v0/urls/videos.py +++ b/analytics_data_api/v1/urls/videos.py @@ -2,7 +2,7 @@ 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'), diff --git a/analytics_data_api/v1/views/__init__.py b/analytics_data_api/v1/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analytics_data_api/v1/views/base.py b/analytics_data_api/v1/views/base.py new file mode 100644 index 00000000..9bd4f6f9 --- /dev/null +++ b/analytics_data_api/v1/views/base.py @@ -0,0 +1,787 @@ +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. + ; rel="next", ; 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()`. + 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 diff --git a/analytics_data_api/v1/views/course_summaries.py b/analytics_data_api/v1/views/course_summaries.py new file mode 100644 index 00000000..0450b92b --- /dev/null +++ b/analytics_data_api/v1/views/course_summaries.py @@ -0,0 +1,318 @@ +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() + } diff --git a/analytics_data_api/v1/views/course_totals.py b/analytics_data_api/v1/views/course_totals.py new file mode 100644 index 00000000..6d3988ce --- /dev/null +++ b/analytics_data_api/v1/views/course_totals.py @@ -0,0 +1,70 @@ +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 diff --git a/analytics_data_api/v0/views/courses.py b/analytics_data_api/v1/views/courses.py similarity index 97% rename from analytics_data_api/v0/views/courses.py rename to analytics_data_api/v1/views/courses.py index d533d3e2..e3f44974 100644 --- a/analytics_data_api/v0/views/courses.py +++ b/analytics_data_api/v1/views/courses.py @@ -15,10 +15,10 @@ 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** diff --git a/analytics_data_api/v0/views/learners.py b/analytics_data_api/v1/views/learners.py similarity index 96% rename from analytics_data_api/v0/views/learners.py rename to analytics_data_api/v1/views/learners.py index 7c015681..6473cced 100644 --- a/analytics_data_api/v0/views/learners.py +++ b/analytics_data_api/v1/views/learners.py @@ -5,26 +5,30 @@ 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** diff --git a/analytics_data_api/v1/views/pagination.py b/analytics_data_api/v1/views/pagination.py new file mode 100644 index 00000000..51d09bc8 --- /dev/null +++ b/analytics_data_api/v1/views/pagination.py @@ -0,0 +1,86 @@ +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' diff --git a/analytics_data_api/v0/views/problems.py b/analytics_data_api/v1/views/problems.py similarity index 94% rename from analytics_data_api/v0/views/problems.py rename to analytics_data_api/v1/views/problems.py index 1010f072..6ecc5042 100644 --- a/analytics_data_api/v0/views/problems.py +++ b/analytics_data_api/v1/views/problems.py @@ -8,13 +8,13 @@ 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.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** diff --git a/analytics_data_api/v1/views/programs.py b/analytics_data_api/v1/views/programs.py new file mode 100644 index 00000000..9e4f13c8 --- /dev/null +++ b/analytics_data_api/v1/views/programs.py @@ -0,0 +1,63 @@ +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), + } diff --git a/analytics_data_api/v0/views/utils.py b/analytics_data_api/v1/views/utils.py similarity index 93% rename from analytics_data_api/v0/views/utils.py rename to analytics_data_api/v1/views/utils.py index 1bc1a663..9c240e4e 100644 --- a/analytics_data_api/v0/views/utils.py +++ b/analytics_data_api/v1/views/utils.py @@ -4,7 +4,7 @@ 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): diff --git a/analytics_data_api/v0/views/videos.py b/analytics_data_api/v1/views/videos.py similarity index 80% rename from analytics_data_api/v0/views/videos.py rename to analytics_data_api/v1/views/videos.py index 79167c8e..80f08168 100644 --- a/analytics_data_api/v0/views/videos.py +++ b/analytics_data_api/v1/views/videos.py @@ -4,10 +4,10 @@ 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** diff --git a/analyticsdataserver/router.py b/analyticsdataserver/router.py index 21ee608c..b3032dba 100644 --- a/analyticsdataserver/router.py +++ b/analyticsdataserver/router.py @@ -7,7 +7,7 @@ def db_for_read(self, model, **hints): # pylint: disable=unused-argument 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 diff --git a/analyticsdataserver/settings/base.py b/analyticsdataserver/settings/base.py index a5bd911c..364091e5 100644 --- a/analyticsdataserver/settings/base.py +++ b/analyticsdataserver/settings/base.py @@ -58,7 +58,7 @@ 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 @@ '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 @@ 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 @@ 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 + }, + }, +} diff --git a/analyticsdataserver/settings/local.py b/analyticsdataserver/settings/local.py index cd15884e..31c68adc 100644 --- a/analyticsdataserver/settings/local.py +++ b/analyticsdataserver/settings/local.py @@ -40,17 +40,6 @@ } ########## 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' diff --git a/analyticsdataserver/settings/test.py b/analyticsdataserver/settings/test.py index fe680002..b9049e16 100644 --- a/analyticsdataserver/settings/test.py +++ b/analyticsdataserver/settings/test.py @@ -40,3 +40,7 @@ # 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', +} diff --git a/analyticsdataserver/tests.py b/analyticsdataserver/tests.py index 9bb8b184..58adde63 100644 --- a/analyticsdataserver/tests.py +++ b/analyticsdataserver/tests.py @@ -13,7 +13,7 @@ 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