From 5c635010f15650670a46af1ca52c14ad706d8f8a Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Thu, 14 Sep 2017 11:59:06 -0400 Subject: [PATCH] Revert "Merge pull request #178 from edx/edx/kdmccormick/course-listing-reimp-final" (#179) This reverts commit 14e3a013b117bab28a9e7ff509cb083613131a4c, reversing changes made to 859d3768a31c40ab68b2536aaa63a8c099d799ed. Reverts API version from v1 back to v0. See EDUCATOR-1355 to track the eventual refactor and re-commit of these changes. --- .../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 + analytics_data_api/{v1 => v0}/apps.py | 2 +- analytics_data_api/{v1 => v0}/connections.py | 0 analytics_data_api/{v1 => v0}/exceptions.py | 0 analytics_data_api/{v1 => v0}/middleware.py | 2 +- analytics_data_api/{v1 => v0}/models.py | 10 +- analytics_data_api/{v1 => v0}/serializers.py | 15 +- .../{v1 => v0}/tests/__init__.py | 0 .../{v1 => v0}/tests/test_connections.py | 6 +- .../{v1 => v0}/tests/test_models.py | 2 +- .../{v1 => v0}/tests/test_serializers.py | 2 +- .../{v1 => v0}/tests/test_urls.py | 2 +- analytics_data_api/{v1 => v0}/tests/utils.py | 0 .../{v1 => v0}/tests/views/__init__.py | 102 +-- .../v0/tests/views/test_course_summaries.py | 174 ++++ .../{v1 => v0}/tests/views/test_courses.py | 32 +- .../tests/views/test_engagement_timelines.py | 8 +- .../{v1 => v0}/tests/views/test_learners.py | 24 +- .../{v1 => v0}/tests/views/test_problems.py | 18 +- .../{v1 => v0}/tests/views/test_programs.py | 10 +- .../{v1 => v0}/tests/views/test_utils.py | 6 +- .../{v1 => v0}/tests/views/test_videos.py | 4 +- .../{v1 => v0}/urls/__init__.py | 14 +- .../{v1 => v0}/urls/course_summaries.py | 2 +- analytics_data_api/{v1 => v0}/urls/courses.py | 4 +- .../{v1 => v0}/urls/learners.py | 4 +- .../{v1 => v0}/urls/problems.py | 2 +- .../{v1 => v0}/urls/programs.py | 2 +- analytics_data_api/{v1 => v0}/urls/videos.py | 2 +- analytics_data_api/v0/views/__init__.py | 267 ++++++ .../v0/views/course_summaries.py | 173 ++++ .../{v1 => v0}/views/courses.py | 30 +- .../{v1 => v0}/views/learners.py | 22 +- .../{v1 => v0}/views/problems.py | 12 +- analytics_data_api/v0/views/programs.py | 63 ++ analytics_data_api/{v1 => v0}/views/utils.py | 2 +- analytics_data_api/{v1 => v0}/views/videos.py | 8 +- analytics_data_api/v1/__init__.py | 1 - .../v1/tests/views/test_course_summaries.py | 462 ---------- .../v1/tests/views/test_course_totals.py | 89 -- analytics_data_api/v1/urls/course_totals.py | 7 - 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 -- analytics_data_api/v1/views/pagination.py | 86 -- analytics_data_api/v1/views/programs.py | 63 -- 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, 865 insertions(+), 2154 deletions(-) create mode 100644 analytics_data_api/v0/__init__.py rename analytics_data_api/{v1 => v0}/apps.py (97%) rename analytics_data_api/{v1 => v0}/connections.py (100%) rename analytics_data_api/{v1 => v0}/exceptions.py (100%) rename analytics_data_api/{v1 => v0}/middleware.py (98%) rename analytics_data_api/{v1 => v0}/models.py (98%) rename analytics_data_api/{v1 => v0}/serializers.py (97%) rename analytics_data_api/{v1 => v0}/tests/__init__.py (100%) rename analytics_data_api/{v1 => v0}/tests/test_connections.py (93%) rename analytics_data_api/{v1 => v0}/tests/test_models.py (96%) rename analytics_data_api/{v1 => v0}/tests/test_serializers.py (94%) rename analytics_data_api/{v1 => v0}/tests/test_urls.py (95%) rename analytics_data_api/{v1 => v0}/tests/utils.py (100%) rename analytics_data_api/{v1 => v0}/tests/views/__init__.py (62%) create mode 100644 analytics_data_api/v0/tests/views/test_course_summaries.py rename analytics_data_api/{v1 => v0}/tests/views/test_courses.py (97%) rename analytics_data_api/{v1 => v0}/tests/views/test_engagement_timelines.py (96%) rename analytics_data_api/{v1 => v0}/tests/views/test_learners.py (98%) rename analytics_data_api/{v1 => v0}/tests/views/test_problems.py (92%) rename analytics_data_api/{v1 => v0}/tests/views/test_programs.py (89%) rename analytics_data_api/{v1 => v0}/tests/views/test_utils.py (88%) rename analytics_data_api/{v1 => v0}/tests/views/test_videos.py (95%) rename analytics_data_api/{v1 => v0}/urls/__init__.py (58%) rename analytics_data_api/{v1 => v0}/urls/course_summaries.py (70%) rename analytics_data_api/{v1 => v0}/urls/courses.py (91%) rename analytics_data_api/{v1 => v0}/urls/learners.py (83%) rename analytics_data_api/{v1 => v0}/urls/problems.py (90%) rename analytics_data_api/{v1 => v0}/urls/programs.py (68%) rename analytics_data_api/{v1 => v0}/urls/videos.py (83%) create mode 100644 analytics_data_api/v0/views/__init__.py create mode 100644 analytics_data_api/v0/views/course_summaries.py rename analytics_data_api/{v1 => v0}/views/courses.py (97%) rename analytics_data_api/{v1 => v0}/views/learners.py (96%) rename analytics_data_api/{v1 => v0}/views/problems.py (94%) create mode 100644 analytics_data_api/v0/views/programs.py rename analytics_data_api/{v1 => v0}/views/utils.py (93%) rename analytics_data_api/{v1 => v0}/views/videos.py (80%) delete mode 100644 analytics_data_api/v1/__init__.py delete mode 100644 analytics_data_api/v1/tests/views/test_course_summaries.py delete mode 100644 analytics_data_api/v1/tests/views/test_course_totals.py delete mode 100644 analytics_data_api/v1/urls/course_totals.py delete mode 100644 analytics_data_api/v1/views/__init__.py delete mode 100644 analytics_data_api/v1/views/base.py delete mode 100644 analytics_data_api/v1/views/course_summaries.py delete mode 100644 analytics_data_api/v1/views/course_totals.py delete mode 100644 analytics_data_api/v1/views/pagination.py delete mode 100644 analytics_data_api/v1/views/programs.py diff --git a/analytics_data_api/constants/enrollment_modes.py b/analytics_data_api/constants/enrollment_modes.py index 1ea62ca9..5d7c4347 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 = frozenset([AUDIT, CREDIT, HONOR, PROFESSIONAL, PROFESSIONAL_NO_ID, VERIFIED]) +ALL = [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 9d1d9217..c693e74c 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": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 1 }, { @@ -32,7 +32,7 @@ "problem_display_name": "Earth Science Question", "question_text": "Enter your answer:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 2 }, { @@ -50,7 +50,7 @@ "problem_display_name": "Earth Science Question", "question_text": "Enter your answer:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 3 }, { @@ -68,7 +68,7 @@ "problem_display_name": "Earth Science Question", "question_text": "Enter your answer:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 4 }, { @@ -86,7 +86,7 @@ "problem_display_name": null, "question_text": null }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 5 }, { @@ -104,7 +104,7 @@ "problem_display_name": null, "question_text": null }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 6 }, { @@ -122,7 +122,7 @@ "problem_display_name": null, "question_text": null }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 7 }, { @@ -140,7 +140,7 @@ "problem_display_name": "Example problem", "question_text": "Enter an answer:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 8 }, { @@ -158,7 +158,7 @@ "problem_display_name": "Example problem", "question_text": "Enter an answer:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 9 }, { @@ -176,7 +176,7 @@ "problem_display_name": "Example problem", "question_text": "Enter an answer:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 10 }, { @@ -194,7 +194,7 @@ "problem_display_name": "Example problem", "question_text": "Randomized answer" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 11 }, { @@ -212,7 +212,7 @@ "problem_display_name": "Example problem", "question_text": "Randomized answer" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 12 }, @@ -231,7 +231,7 @@ "problem_display_name": "Example problem", "question_text": "Select from the choices below:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 13 }, { @@ -249,7 +249,7 @@ "problem_display_name": "Example problem", "question_text": "Select from the choices below:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.problemfirstlastresponseanswerdistribution", "pk": 14 }, { @@ -267,7 +267,7 @@ "problem_display_name": "Example problem", "question_text": "Select from the choices below:" }, - "model": "v1.problemfirstlastresponseanswerdistribution", + "model": "v0.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 c2c092e6..9f5b82f6 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.v1 import models +from analytics_data_api.v0 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 d8d2c8f2..15bf51b1 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'^v1/', include('analytics_data_api.v1.urls', 'v1')), + url(r'^v0/', include('analytics_data_api.v0.urls', 'v0')), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/analytics_data_api/utils.py b/analytics_data_api/utils.py index 682f37f1..24b7886d 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.v1.exceptions import ( +from analytics_data_api.v0.exceptions import ( ReportFileNotFoundError, CannotCreateReportDownloadLinkError ) @@ -230,25 +230,3 @@ 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 new file mode 100644 index 00000000..ce955d0d --- /dev/null +++ b/analytics_data_api/v0/__init__.py @@ -0,0 +1 @@ +default_app_config = 'analytics_data_api.v0.apps.ApiAppConfig' diff --git a/analytics_data_api/v1/apps.py b/analytics_data_api/v0/apps.py similarity index 97% rename from analytics_data_api/v1/apps.py rename to analytics_data_api/v0/apps.py index 5757302c..4e7f6fdb 100644 --- a/analytics_data_api/v1/apps.py +++ b/analytics_data_api/v0/apps.py @@ -5,7 +5,7 @@ class ApiAppConfig(AppConfig): - name = 'analytics_data_api.v1' + name = 'analytics_data_api.v0' def ready(self): from analytics_data_api.utils import load_fully_qualified_definition diff --git a/analytics_data_api/v1/connections.py b/analytics_data_api/v0/connections.py similarity index 100% rename from analytics_data_api/v1/connections.py rename to analytics_data_api/v0/connections.py diff --git a/analytics_data_api/v1/exceptions.py b/analytics_data_api/v0/exceptions.py similarity index 100% rename from analytics_data_api/v1/exceptions.py rename to analytics_data_api/v0/exceptions.py diff --git a/analytics_data_api/v1/middleware.py b/analytics_data_api/v0/middleware.py similarity index 98% rename from analytics_data_api/v1/middleware.py rename to analytics_data_api/v0/middleware.py index 7b6916a1..b1ce0b12 100644 --- a/analytics_data_api/v1/middleware.py +++ b/analytics_data_api/v0/middleware.py @@ -2,7 +2,7 @@ from django.http.response import JsonResponse from rest_framework import status -from analytics_data_api.v1.exceptions import ( +from analytics_data_api.v0.exceptions import ( CourseKeyMalformedError, CourseNotSpecifiedError, LearnerEngagementTimelineNotFoundError, diff --git a/analytics_data_api/v1/models.py b/analytics_data_api/v0/models.py similarity index 98% rename from analytics_data_api/v1/models.py rename to analytics_data_api/v0/models.py index e5b88651..9046b5e1 100644 --- a/analytics_data_api/v1/models.py +++ b/analytics_data_api/v0/models.py @@ -68,12 +68,12 @@ class Meta(BaseCourseEnrollment.Meta): class CourseMetaSummaryEnrollment(BaseCourseModel): - catalog_course_title = models.CharField(db_index=True, null=True, max_length=255) + catalog_course_title = models.CharField(db_index=True, max_length=255) catalog_course = 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) + 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) enrollment_mode = models.CharField(max_length=255) count = models.IntegerField(null=False) cumulative_count = models.IntegerField(null=False) diff --git a/analytics_data_api/v1/serializers.py b/analytics_data_api/v0/serializers.py similarity index 97% rename from analytics_data_api/v1/serializers.py rename to analytics_data_api/v0/serializers.py index 421966c2..55101733 100644 --- a/analytics_data_api/v1/serializers.py +++ b/analytics_data_api/v0/serializers.py @@ -8,7 +8,7 @@ engagement_events, enrollment_modes, ) -from analytics_data_api.v1 import models +from analytics_data_api.v0 import models # Below are the enrollment modes supported by this API. @@ -561,7 +561,6 @@ 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() @@ -570,7 +569,7 @@ def get_enrollment_modes(self, obj): return obj.get('enrollment_modes', None) def get_programs(self, obj): - return list(obj.get('programs', set())) + return obj.get('programs', None) class Meta(object): model = models.CourseMetaSummaryEnrollment @@ -595,13 +594,3 @@ 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/v1/tests/__init__.py b/analytics_data_api/v0/tests/__init__.py similarity index 100% rename from analytics_data_api/v1/tests/__init__.py rename to analytics_data_api/v0/tests/__init__.py diff --git a/analytics_data_api/v1/tests/test_connections.py b/analytics_data_api/v0/tests/test_connections.py similarity index 93% rename from analytics_data_api/v1/tests/test_connections.py rename to analytics_data_api/v0/tests/test_connections.py index a3970716..d4eb43d4 100644 --- a/analytics_data_api/v1/tests/test_connections.py +++ b/analytics_data_api/v0/tests/test_connections.py @@ -4,7 +4,7 @@ from elasticsearch.exceptions import ElasticsearchException from mock import patch -from analytics_data_api.v1.connections import BotoHttpConnection, ESConnection +from analytics_data_api.v0.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.v1.connections.ESConnection.make_request') + @patch('analytics_data_api.v0.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.v1.connections.ESConnection.make_request') + @patch('analytics_data_api.v0.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/v1/tests/test_models.py b/analytics_data_api/v0/tests/test_models.py similarity index 96% rename from analytics_data_api/v1/tests/test_models.py rename to analytics_data_api/v0/tests/test_models.py index 2eefcc81..8806d8c5 100644 --- a/analytics_data_api/v1/tests/test_models.py +++ b/analytics_data_api/v0/tests/test_models.py @@ -1,7 +1,7 @@ from django.test import TestCase from django_dynamic_fixture import G -from analytics_data_api.v1 import models +from analytics_data_api.v0 import models from analytics_data_api.constants.country import UNKNOWN_COUNTRY, get_country diff --git a/analytics_data_api/v1/tests/test_serializers.py b/analytics_data_api/v0/tests/test_serializers.py similarity index 94% rename from analytics_data_api/v1/tests/test_serializers.py rename to analytics_data_api/v0/tests/test_serializers.py index de86341c..42028dee 100644 --- a/analytics_data_api/v1/tests/test_serializers.py +++ b/analytics_data_api/v0/tests/test_serializers.py @@ -2,7 +2,7 @@ from django.test import TestCase from django_dynamic_fixture import G -from analytics_data_api.v1 import models as api_models, serializers as api_serializers +from analytics_data_api.v0 import models as api_models, serializers as api_serializers class TestSerializer(api_serializers.CourseEnrollmentDailySerializer, api_serializers.DynamicFieldsModelSerializer): diff --git a/analytics_data_api/v1/tests/test_urls.py b/analytics_data_api/v0/tests/test_urls.py similarity index 95% rename from analytics_data_api/v1/tests/test_urls.py rename to analytics_data_api/v0/tests/test_urls.py index 6ff831c0..f2f7d700 100644 --- a/analytics_data_api/v1/tests/test_urls.py +++ b/analytics_data_api/v0/tests/test_urls.py @@ -3,7 +3,7 @@ class UrlRedirectTests(TestCase): - api_root_path = '/api/v1/' + api_root_path = '/api/v0/' def assertRedirectsToRootPath(self, path, **kwargs): assert_kwargs = {'status_code': 302} diff --git a/analytics_data_api/v1/tests/utils.py b/analytics_data_api/v0/tests/utils.py similarity index 100% rename from analytics_data_api/v1/tests/utils.py rename to analytics_data_api/v0/tests/utils.py diff --git a/analytics_data_api/v1/tests/views/__init__.py b/analytics_data_api/v0/tests/views/__init__.py similarity index 62% rename from analytics_data_api/v1/tests/views/__init__.py rename to analytics_data_api/v0/tests/views/__init__.py index 5d9910ad..16f70b7e 100644 --- a/analytics_data_api/v1/tests/views/__init__.py +++ b/analytics_data_api/v0/tests/views/__init__.py @@ -7,7 +7,7 @@ from django_dynamic_fixture import G from rest_framework import status -from analytics_data_api.v1.tests.utils import flatten +from analytics_data_api.v0.tests.utils import flatten class CourseSamples(object): @@ -18,8 +18,6 @@ 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', @@ -102,29 +100,28 @@ 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/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 + 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 def create_model(self, model_id, **kwargs): pass # implement in subclass @@ -151,18 +148,20 @@ def all_expected_results(self, ids=None, **kwargs): def _test_all_items(self, ids): self.generate_data() - data = self.validated_request(200, ids=ids, exclude=self.always_exclude) - self.assertItemsEqual(data, self.all_expected_results(ids=ids)) + 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)) def _test_one_item(self, item_id): self.generate_data() - 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) + 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)]) def _test_fields(self, fields): self.generate_data() - data = self.validated_request(200, fields=fields) + response = self.validated_request(fields=fields) + self.assertEquals(response.status_code, 200) # remove fields not requested from expected results expected_results = self.all_expected_results() @@ -170,52 +169,13 @@ def _test_fields(self, fields): for field_to_remove in set(expected_result.keys()) - set(fields): expected_result.pop(field_to_remove) - self.assertItemsEqual(data, expected_results) + self.assertItemsEqual(response.data, expected_results) def test_no_items(self): - data = self.validated_request(200) - self.assertEqual(data, []) + response = self.validated_request() + self.assertEquals(response.status_code, 404) def test_no_matching_items(self): self.generate_data() - 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 + response = self.validated_request(ids=['no/items/found']) + self.assertEquals(response.status_code, 404) diff --git a/analytics_data_api/v0/tests/views/test_course_summaries.py b/analytics_data_api/v0/tests/views/test_course_summaries.py new file mode 100644 index 00000000..37b01c63 --- /dev/null +++ b/analytics_data_api/v0/tests/views/test_course_summaries.py @@ -0,0 +1,174 @@ +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/v1/tests/views/test_courses.py b/analytics_data_api/v0/tests/views/test_courses.py similarity index 97% rename from analytics_data_api/v1/tests/views/test_courses.py rename to analytics_data_api/v0/tests/views/test_courses.py index 1ffd0f6a..bc2093a3 100644 --- a/analytics_data_api/v1/tests/views/test_courses.py +++ b/analytics_data_api/v0/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.v1 import models -from analytics_data_api.v1.tests.views import CourseSamples, VerifyCsvResponseMixin +from analytics_data_api.v0 import models +from analytics_data_api.v0.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/v1/' + api_root_path = '/api/v0/' 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/v1/courses/{0}/recent_activity'.format(course_id)) + response = self.authenticated_get(u'/api/v0/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/v1/courses/{0}/recent_activity?activity_type={1}'.format( + response = self.authenticated_get(u'/api/v0/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/v1/courses/{0}/recent_activity'.format(course_id), follow=True) + response = self.client.get(u'/api/v0/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/v1/courses/{}/recent_activity'.format(url_encoded_course_id)) + response = self.authenticated_get(u'/api/v0/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/v1/courses/{0}/recent_activity?activity_type={1}'.format( + response = self.authenticated_get(u'/api/v0/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/v1/courses/{0}/recent_activity'.format('foo')) + response = self.authenticated_get(u'/api/v0/courses/{0}/recent_activity'.format('foo')) self.assertEquals(response.status_code, 404) def test_missing_course_id(self): - response = self.authenticated_get(u'/api/v1/courses/recent_activity') + response = self.authenticated_get(u'/api/v0/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/v1/courses/{0}/recent_activity?label={1}'.format( + response = self.authenticated_get(u'/api/v0/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/v1/courses/%s%s' % (course_id, self.path,)) + response = self.authenticated_get('/api/v0/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/v1/courses/{}/problems/'.format(course_id) + url = '/api/v0/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/v1/courses/{}/problems_and_tags/'.format(course_id) + url = '/api/v0/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/v1/courses/{}/videos/'.format(course_id) + url = '/api/v0/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/v1/courses/{course_id}/reports/{report_name}' + path = '/api/v0/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/v1/tests/views/test_engagement_timelines.py b/analytics_data_api/v0/tests/views/test_engagement_timelines.py similarity index 96% rename from analytics_data_api/v1/tests/views/test_engagement_timelines.py rename to analytics_data_api/v0/tests/views/test_engagement_timelines.py index 5262279c..50618e2f 100644 --- a/analytics_data_api/v1/tests/views/test_engagement_timelines.py +++ b/analytics_data_api/v0/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.v1 import models -from analytics_data_api.v1.tests.views import CourseSamples, VerifyCourseIdMixin +from analytics_data_api.v0 import models +from analytics_data_api.v0.tests.views import CourseSamples, VerifyCourseIdMixin @ddt.ddt class EngagementTimelineTests(VerifyCourseIdMixin, TestCaseWithAuthentication): DEFAULT_USERNAME = 'ed_xavier' - path_template = '/api/v1/engagement_timelines/{}/?course_id={}' + path_template = '/api/v0/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/v1/engagement_timelines/{}' + base_path = '/api/v0/engagement_timelines/{}' response = self.authenticated_get((base_path).format('ed_xavier')) self.verify_no_course_id(response) diff --git a/analytics_data_api/v1/tests/views/test_learners.py b/analytics_data_api/v0/tests/views/test_learners.py similarity index 98% rename from analytics_data_api/v1/tests/views/test_learners.py rename to analytics_data_api/v0/tests/views/test_learners.py index 3c8d3f07..a11d1176 100644 --- a/analytics_data_api/v1/tests/views/test_learners.py +++ b/analytics_data_api/v0/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.v1.models import ModuleEngagementMetricRanges -from analytics_data_api.v1.views.base import CsvViewMixin, PaginatedHeadersMixin -from analytics_data_api.v1.tests.views import ( +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 ( 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/v1/learners/?{course_q}{page_q}{page_size_q}'.format( + return 'http://testserver/api/v0/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/v1/learners/{}/?course_id={}' + path_template = '/api/v0/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.v1.models.RosterEntry.get_course_user', Mock(return_value=[])) + @patch('analytics_data_api.v0.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/v1/learners/{}' + base_path = '/api/v0/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/v1/learners/', query_params) + return self.authenticated_get('/api/v0/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/v1/learners/', parameters) + response = self.authenticated_get('/api/v0/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/v1/learners/' + self.path = '/api/v0/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/v1/course_learner_metadata/{}/'.format(course_id)) + return self.authenticated_get('/api/v0/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/v1/course_learner_metadata/') + response = self.authenticated_get('/api/v0/course_learner_metadata/') self.assertEqual(response.status_code, 404) @ddt.data( diff --git a/analytics_data_api/v1/tests/views/test_problems.py b/analytics_data_api/v0/tests/views/test_problems.py similarity index 92% rename from analytics_data_api/v1/tests/views/test_problems.py rename to analytics_data_api/v0/tests/views/test_problems.py index 79029c13..699b0176 100644 --- a/analytics_data_api/v1/tests/views/test_problems.py +++ b/analytics_data_api/v0/tests/views/test_problems.py @@ -9,8 +9,8 @@ from django_dynamic_fixture import G -from analytics_data_api.v1 import models -from analytics_data_api.v1.serializers import ProblemFirstLastResponseAnswerDistributionSerializer, \ +from analytics_data_api.v0 import models +from analytics_data_api.v0.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/v1/problems/%s%s' % (self.module_id2, self.path)) + response = self.authenticated_get('/api/v0/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/v1/problems/{0}{1}'.format(self.module_id1, self.path)) + '/api/v0/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/v1/problems/%s%s' % ("DOES-NOT-EXIST", self.path)) + response = self.authenticated_get('/api/v0/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/v1/problems/%s%s' % (self.module_id, self.path)) + response = self.authenticated_get('/api/v0/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/v1/problems/%s%s' % ("DOES-NOT-EXIST", self.path)) + response = self.authenticated_get('/api/v0/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/v1/problems/%s%s' % (self.module_id, self.path)) + response = self.authenticated_get('/api/v0/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/v1/problems/%s%s' % ("DOES-NOT-EXIST", self.path)) + response = self.authenticated_get('/api/v0/problems/%s%s' % ("DOES-NOT-EXIST", self.path)) self.assertEquals(response.status_code, 404) diff --git a/analytics_data_api/v1/tests/views/test_programs.py b/analytics_data_api/v0/tests/views/test_programs.py similarity index 89% rename from analytics_data_api/v1/tests/views/test_programs.py rename to analytics_data_api/v0/tests/views/test_programs.py index 822e5505..4df335be 100644 --- a/analytics_data_api/v1/tests/views/test_programs.py +++ b/analytics_data_api/v0/tests/views/test_programs.py @@ -2,8 +2,8 @@ import ddt from django_dynamic_fixture import G -from analytics_data_api.v1 import models, serializers -from analytics_data_api.v1.tests.views import CourseSamples, APIListViewTestMixin +from analytics_data_api.v0 import models, serializers +from analytics_data_api.v0.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) - 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) + 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)) diff --git a/analytics_data_api/v1/tests/views/test_utils.py b/analytics_data_api/v0/tests/views/test_utils.py similarity index 88% rename from analytics_data_api/v1/tests/views/test_utils.py rename to analytics_data_api/v0/tests/views/test_utils.py index f5f146c6..0fa7a39f 100644 --- a/analytics_data_api/v1/tests/views/test_utils.py +++ b/analytics_data_api/v0/tests/views/test_utils.py @@ -4,9 +4,9 @@ from django.http import Http404 from django.test import TestCase -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 +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 @ddt.ddt diff --git a/analytics_data_api/v1/tests/views/test_videos.py b/analytics_data_api/v0/tests/views/test_videos.py similarity index 95% rename from analytics_data_api/v1/tests/views/test_videos.py rename to analytics_data_api/v0/tests/views/test_videos.py index b865e09a..eb08ba99 100644 --- a/analytics_data_api/v1/tests/views/test_videos.py +++ b/analytics_data_api/v0/tests/views/test_videos.py @@ -4,14 +4,14 @@ from django.utils import timezone from django_dynamic_fixture import G -from analytics_data_api.v1 import models +from analytics_data_api.v0 import models from analyticsdataserver.tests import TestCaseWithAuthentication class VideoTimelineTests(TestCaseWithAuthentication): def _get_data(self, video_id=None): - return self.authenticated_get('/api/v1/videos/{}/timeline'.format(video_id)) + return self.authenticated_get('/api/v0/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/v1/urls/__init__.py b/analytics_data_api/v0/urls/__init__.py similarity index 58% rename from analytics_data_api/v1/urls/__init__.py rename to analytics_data_api/v0/urls/__init__.py index ec54ad11..ba4de848 100644 --- a/analytics_data_api/v1/urls/__init__.py +++ b/analytics_data_api/v0/urls/__init__.py @@ -2,17 +2,15 @@ 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.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')), + 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')), # pylint: disable=no-value-for-parameter url(r'^authenticated/$', RedirectView.as_view(url=reverse_lazy('authenticated')), name='authenticated'), diff --git a/analytics_data_api/v1/urls/course_summaries.py b/analytics_data_api/v0/urls/course_summaries.py similarity index 70% rename from analytics_data_api/v1/urls/course_summaries.py rename to analytics_data_api/v0/urls/course_summaries.py index c79be4dd..a03bb27b 100644 --- a/analytics_data_api/v1/urls/course_summaries.py +++ b/analytics_data_api/v0/urls/course_summaries.py @@ -1,6 +1,6 @@ from django.conf.urls import url -from analytics_data_api.v1.views import course_summaries as views +from analytics_data_api.v0.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/courses.py b/analytics_data_api/v0/urls/courses.py similarity index 91% rename from analytics_data_api/v1/urls/courses.py rename to analytics_data_api/v0/urls/courses.py index c88ce6a9..463e912d 100644 --- a/analytics_data_api/v1/urls/courses.py +++ b/analytics_data_api/v0/urls/courses.py @@ -1,7 +1,7 @@ from django.conf.urls import url -from analytics_data_api.v1.urls import COURSE_ID_PATTERN -from analytics_data_api.v1.views import courses as views +from analytics_data_api.v0.urls import COURSE_ID_PATTERN +from analytics_data_api.v0.views import courses as views COURSE_URLS = [ ('activity', views.CourseActivityWeeklyView, 'activity'), diff --git a/analytics_data_api/v1/urls/learners.py b/analytics_data_api/v0/urls/learners.py similarity index 83% rename from analytics_data_api/v1/urls/learners.py rename to analytics_data_api/v0/urls/learners.py index e97976be..f718cdce 100644 --- a/analytics_data_api/v1/urls/learners.py +++ b/analytics_data_api/v0/urls/learners.py @@ -1,7 +1,7 @@ from django.conf.urls import url -from analytics_data_api.v1.urls import COURSE_ID_PATTERN -from analytics_data_api.v1.views import learners as views +from analytics_data_api.v0.urls import COURSE_ID_PATTERN +from analytics_data_api.v0.views import learners as views USERNAME_PATTERN = r'(?P[\w.+-]+)' diff --git a/analytics_data_api/v1/urls/problems.py b/analytics_data_api/v0/urls/problems.py similarity index 90% rename from analytics_data_api/v1/urls/problems.py rename to analytics_data_api/v0/urls/problems.py index 4ba35a18..edfac7c1 100644 --- a/analytics_data_api/v1/urls/problems.py +++ b/analytics_data_api/v0/urls/problems.py @@ -2,7 +2,7 @@ from django.conf.urls import url -from analytics_data_api.v1.views import problems as views +from analytics_data_api.v0.views import problems as views PROBLEM_URLS = [ ('answer_distribution', views.ProblemResponseAnswerDistributionView, 'answer_distribution'), diff --git a/analytics_data_api/v1/urls/programs.py b/analytics_data_api/v0/urls/programs.py similarity index 68% rename from analytics_data_api/v1/urls/programs.py rename to analytics_data_api/v0/urls/programs.py index f52ca97d..1254ac87 100644 --- a/analytics_data_api/v1/urls/programs.py +++ b/analytics_data_api/v0/urls/programs.py @@ -1,6 +1,6 @@ from django.conf.urls import url -from analytics_data_api.v1.views import programs as views +from analytics_data_api.v0.views import programs as views urlpatterns = [ url(r'^programs/$', views.ProgramsView.as_view(), name='programs'), diff --git a/analytics_data_api/v1/urls/videos.py b/analytics_data_api/v0/urls/videos.py similarity index 83% rename from analytics_data_api/v1/urls/videos.py rename to analytics_data_api/v0/urls/videos.py index 3e13dd48..466eb7e9 100644 --- a/analytics_data_api/v1/urls/videos.py +++ b/analytics_data_api/v0/urls/videos.py @@ -2,7 +2,7 @@ from django.conf.urls import url -from analytics_data_api.v1.views import videos as views +from analytics_data_api.v0.views import videos as views VIDEO_URLS = [ ('timeline', views.VideoTimelineView, 'timeline'), diff --git a/analytics_data_api/v0/views/__init__.py b/analytics_data_api/v0/views/__init__.py new file mode 100644 index 00000000..652097c4 --- /dev/null +++ b/analytics_data_api/v0/views/__init__.py @@ -0,0 +1,267 @@ +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 new file mode 100644 index 00000000..431113e5 --- /dev/null +++ b/analytics_data_api/v0/views/course_summaries.py @@ -0,0 +1,173 @@ +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/v1/views/courses.py b/analytics_data_api/v0/views/courses.py similarity index 97% rename from analytics_data_api/v1/views/courses.py rename to analytics_data_api/v0/views/courses.py index e3f44974..d533d3e2 100644 --- a/analytics_data_api/v1/views/courses.py +++ b/analytics_data_api/v0/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.v1 import models, serializers -from analytics_data_api.v1.exceptions import ReportFileNotFoundError +from analytics_data_api.v0 import models, serializers +from analytics_data_api.v0.exceptions import ReportFileNotFoundError -from analytics_data_api.v1.views.utils import raise_404_if_none +from analytics_data_api.v0.views.utils import raise_404_if_none class BaseCourseView(generics.ListAPIView): @@ -75,7 +75,7 @@ class CourseActivityWeeklyView(BaseCourseView): **Example request** - GET /api/v1/courses/{course_id}/activity/ + GET /api/v0/courses/{course_id}/activity/ **Response Values** @@ -183,7 +183,7 @@ class CourseActivityMostRecentWeekView(generics.RetrieveAPIView): **Example request** - GET /api/v1/courses/{course_id}/recent_activity/ + GET /api/v0/courses/{course_id}/recent_activity/ **Response Values** @@ -283,7 +283,7 @@ class CourseEnrollmentByBirthYearView(BaseCourseEnrollmentView): **Example request** - GET /api/v1/courses/{course_id}/enrollment/birth_year/ + GET /api/v0/courses/{course_id}/enrollment/birth_year/ **Response Values** @@ -323,7 +323,7 @@ class CourseEnrollmentByEducationView(BaseCourseEnrollmentView): **Example request** - GET /api/v1/courses/{course_id}/enrollment/education/ + GET /api/v0/courses/{course_id}/enrollment/education/ **Response Values** @@ -364,7 +364,7 @@ class CourseEnrollmentByGenderView(BaseCourseEnrollmentView): **Example request** - GET /api/v1/courses/{course_id}/enrollment/gender/ + GET /api/v0/courses/{course_id}/enrollment/gender/ **Response Values** @@ -431,7 +431,7 @@ class CourseEnrollmentView(BaseCourseEnrollmentView): **Example request** - GET /api/v1/courses/{course_id}/enrollment/ + GET /api/v0/courses/{course_id}/enrollment/ **Response Values** @@ -468,7 +468,7 @@ class CourseEnrollmentModeView(BaseCourseEnrollmentView): **Example request** - GET /api/v1/courses/{course_id}/enrollment/mode/ + GET /api/v0/courses/{course_id}/enrollment/mode/ **Response Values** @@ -548,7 +548,7 @@ class CourseEnrollmentByLocationView(BaseCourseEnrollmentView): **Example request** - GET /api/v1/courses/{course_id}/enrollment/location/ + GET /api/v0/courses/{course_id}/enrollment/location/ **Response Values** @@ -629,7 +629,7 @@ class ProblemsListView(BaseCourseView): **Example request** - GET /api/v1/courses/{course_id}/problems/ + GET /api/v0/courses/{course_id}/problems/ **Response Values** @@ -705,7 +705,7 @@ class ProblemsAndTagsListView(BaseCourseView): **Example request** - GET /api/v1/courses/{course_id}/problems_and_tags/ + GET /api/v0/courses/{course_id}/problems_and_tags/ **Response Values** @@ -755,7 +755,7 @@ class VideosListView(BaseCourseView): **Example request** - GET /api/v1/courses/{course_id}/videos/ + GET /api/v0/courses/{course_id}/videos/ **Response Values** @@ -786,7 +786,7 @@ class ReportDownloadView(APIView): **Example request** - GET /api/v1/courses/{course_id}/reports/{report_name}/ + GET /api/v0/courses/{course_id}/reports/{report_name}/ **Response Values** diff --git a/analytics_data_api/v1/views/learners.py b/analytics_data_api/v0/views/learners.py similarity index 96% rename from analytics_data_api/v1/views/learners.py rename to analytics_data_api/v0/views/learners.py index 6473cced..7c015681 100644 --- a/analytics_data_api/v1/views/learners.py +++ b/analytics_data_api/v0/views/learners.py @@ -5,30 +5,26 @@ from rest_framework import generics, status -from analytics_data_api.v1.exceptions import ( +from analytics_data_api.v0.exceptions import ( LearnerEngagementTimelineNotFoundError, LearnerNotFoundError, ParameterValueError, ) -from analytics_data_api.v1.models import ( +from analytics_data_api.v0.models import ( ModuleEngagement, ModuleEngagementMetricRanges, RosterEntry, RosterUpdate, ) -from analytics_data_api.v1.serializers import ( +from analytics_data_api.v0.serializers import ( CourseLearnerMetadataSerializer, EdxPaginationSerializer, EngagementDaySerializer, LastUpdatedSerializer, LearnerSerializer, ) -from analytics_data_api.v1.views.base import ( - CourseViewMixin, - PaginatedHeadersMixin, - CsvViewMixin, -) -from analytics_data_api.v1.views.utils import split_query_argument +from analytics_data_api.v0.views import CourseViewMixin, PaginatedHeadersMixin, CsvViewMixin +from analytics_data_api.v0.views.utils import split_query_argument logger = logging.getLogger(__name__) @@ -54,7 +50,7 @@ class LearnerView(LastUpdateMixin, CourseViewMixin, generics.RetrieveAPIView): **Example Request** - GET /api/v1/learners/{username}/?course_id={course_id} + GET /api/v0/learners/{username}/?course_id={course_id} **Response Values** @@ -130,7 +126,7 @@ class LearnerListView(LastUpdateMixin, CourseViewMixin, PaginatedHeadersMixin, C **Example Request** - GET /api/v1/learners/?course_id={course_id} + GET /api/v0/learners/?course_id={course_id} **Response Values** @@ -309,7 +305,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView): **Example Request** - GET /api/v1/engagement_timeline/{username}/?course_id={course_id} + GET /api/v0/engagement_timeline/{username}/?course_id={course_id} **Response Values** @@ -366,7 +362,7 @@ class CourseLearnerMetadata(CourseViewMixin, generics.RetrieveAPIView): **Example Request** - GET /api/v1/course_learner_metadata/{course_id}/ + GET /api/v0/course_learner_metadata/{course_id}/ **Response Values** diff --git a/analytics_data_api/v1/views/problems.py b/analytics_data_api/v0/views/problems.py similarity index 94% rename from analytics_data_api/v1/views/problems.py rename to analytics_data_api/v0/views/problems.py index 6ecc5042..1010f072 100644 --- a/analytics_data_api/v1/views/problems.py +++ b/analytics_data_api/v0/views/problems.py @@ -8,13 +8,13 @@ from django.db import OperationalError from rest_framework import generics -from analytics_data_api.v1.models import ( +from analytics_data_api.v0.models import ( GradeDistribution, ProblemResponseAnswerDistribution, ProblemFirstLastResponseAnswerDistribution, SequentialOpenDistribution, ) -from analytics_data_api.v1.serializers import ( +from analytics_data_api.v0.serializers import ( ConsolidatedAnswerDistributionSerializer, ConsolidatedFirstLastAnswerDistributionSerializer, GradeDistributionSerializer, @@ -22,7 +22,7 @@ ) from analytics_data_api.utils import matching_tuple -from analytics_data_api.v1.views.utils import raise_404_if_none +from analytics_data_api.v0.views.utils import raise_404_if_none class ProblemResponseAnswerDistributionView(generics.ListAPIView): @@ -31,7 +31,7 @@ class ProblemResponseAnswerDistributionView(generics.ListAPIView): **Example request** - GET /api/v1/problems/{problem_id}/answer_distribution + GET /api/v0/problems/{problem_id}/answer_distribution **Response Values** @@ -126,7 +126,7 @@ class GradeDistributionView(generics.ListAPIView): **Example request** - GET /api/v1/problems/{problem_id}/grade_distribution + GET /api/v0/problems/{problem_id}/grade_distribution **Response Values** @@ -158,7 +158,7 @@ class SequentialOpenDistributionView(generics.ListAPIView): **Example request** - GET /api/v1/problems/{module_id}/sequential_open_distribution + GET /api/v0/problems/{module_id}/sequential_open_distribution **Response Values** diff --git a/analytics_data_api/v0/views/programs.py b/analytics_data_api/v0/views/programs.py new file mode 100644 index 00000000..3e44ec72 --- /dev/null +++ b/analytics_data_api/v0/views/programs.py @@ -0,0 +1,63 @@ +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/views/utils.py b/analytics_data_api/v0/views/utils.py similarity index 93% rename from analytics_data_api/v1/views/utils.py rename to analytics_data_api/v0/views/utils.py index 9c240e4e..1bc1a663 100644 --- a/analytics_data_api/v1/views/utils.py +++ b/analytics_data_api/v0/views/utils.py @@ -4,7 +4,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from analytics_data_api.v1.exceptions import CourseKeyMalformedError +from analytics_data_api.v0.exceptions import CourseKeyMalformedError def split_query_argument(argument): diff --git a/analytics_data_api/v1/views/videos.py b/analytics_data_api/v0/views/videos.py similarity index 80% rename from analytics_data_api/v1/views/videos.py rename to analytics_data_api/v0/views/videos.py index 80f08168..79167c8e 100644 --- a/analytics_data_api/v1/views/videos.py +++ b/analytics_data_api/v0/views/videos.py @@ -4,10 +4,10 @@ from rest_framework import generics -from analytics_data_api.v1.models import VideoTimeline -from analytics_data_api.v1.serializers import VideoTimelineSerializer +from analytics_data_api.v0.models import VideoTimeline +from analytics_data_api.v0.serializers import VideoTimelineSerializer -from analytics_data_api.v1.views.utils import raise_404_if_none +from analytics_data_api.v0.views.utils import raise_404_if_none class VideoTimelineView(generics.ListAPIView): @@ -16,7 +16,7 @@ class VideoTimelineView(generics.ListAPIView): **Example Request** - GET /api/v1/videos/{video_id}/timeline/ + GET /api/v0/videos/{video_id}/timeline/ **Response Values** diff --git a/analytics_data_api/v1/__init__.py b/analytics_data_api/v1/__init__.py deleted file mode 100644 index 2b6aee62..00000000 --- a/analytics_data_api/v1/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'analytics_data_api.v1.apps.ApiAppConfig' diff --git a/analytics_data_api/v1/tests/views/test_course_summaries.py b/analytics_data_api/v1/tests/views/test_course_summaries.py deleted file mode 100644 index 7d9074bf..00000000 --- a/analytics_data_api/v1/tests/views/test_course_summaries.py +++ /dev/null @@ -1,462 +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.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 deleted file mode 100644 index 6709796a..00000000 --- a/analytics_data_api/v1/tests/views/test_course_totals.py +++ /dev/null @@ -1,89 +0,0 @@ -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/v1/urls/course_totals.py b/analytics_data_api/v1/urls/course_totals.py deleted file mode 100644 index 6b3e20df..00000000 --- a/analytics_data_api/v1/urls/course_totals.py +++ /dev/null @@ -1,7 +0,0 @@ -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/v1/views/__init__.py b/analytics_data_api/v1/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/analytics_data_api/v1/views/base.py b/analytics_data_api/v1/views/base.py deleted file mode 100644 index 9bd4f6f9..00000000 --- a/analytics_data_api/v1/views/base.py +++ /dev/null @@ -1,787 +0,0 @@ -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 deleted file mode 100644 index 0450b92b..00000000 --- a/analytics_data_api/v1/views/course_summaries.py +++ /dev/null @@ -1,318 +0,0 @@ -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 deleted file mode 100644 index 6d3988ce..00000000 --- a/analytics_data_api/v1/views/course_totals.py +++ /dev/null @@ -1,70 +0,0 @@ -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/v1/views/pagination.py b/analytics_data_api/v1/views/pagination.py deleted file mode 100644 index 51d09bc8..00000000 --- a/analytics_data_api/v1/views/pagination.py +++ /dev/null @@ -1,86 +0,0 @@ -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/v1/views/programs.py b/analytics_data_api/v1/views/programs.py deleted file mode 100644 index 9e4f13c8..00000000 --- a/analytics_data_api/v1/views/programs.py +++ /dev/null @@ -1,63 +0,0 @@ -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/analyticsdataserver/router.py b/analyticsdataserver/router.py index b3032dba..21ee608c 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 == 'v1': + if app_label == 'v0': return getattr(settings, 'ANALYTICS_DATABASE', 'default') return None diff --git a/analyticsdataserver/settings/base.py b/analyticsdataserver/settings/base.py index 364091e5..a5bd911c 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.v1.connections.BotoHttpConnection' +# e.g. 'analytics_data_api.v0.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.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', + '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', ) ########## END MIDDLEWARE CONFIGURATION @@ -202,7 +202,7 @@ LOCAL_APPS = ( 'analytics_data_api', - 'analytics_data_api.v1', + 'analytics_data_api.v0', ) # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -324,15 +324,3 @@ 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 31c68adc..cd15884e 100644 --- a/analyticsdataserver/settings/local.py +++ b/analyticsdataserver/settings/local.py @@ -40,6 +40,17 @@ } ########## 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 b9049e16..fe680002 100644 --- a/analyticsdataserver/settings/test.py +++ b/analyticsdataserver/settings/test.py @@ -40,7 +40,3 @@ # 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 58adde63..9bb8b184 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.v1.models import CourseEnrollmentDaily, CourseEnrollmentByBirthYear +from analytics_data_api.v0.models import CourseEnrollmentDaily, CourseEnrollmentByBirthYear from analyticsdataserver.clients import CourseBlocksApiClient from analyticsdataserver.router import AnalyticsApiRouter from analyticsdataserver.utils import temp_log_level