diff --git a/Makefile b/Makefile index 41abf3bbc..27758ad9b 100644 --- a/Makefile +++ b/Makefile @@ -78,9 +78,6 @@ runserver_a11y: $(TOX)python manage.py runserver 0.0.0.0:9000 --noreload --traceback > dashboard.log 2>&1 & accept: runserver_a11y -ifeq ("${DISPLAY_LEARNER_ANALYTICS}", "True") - $(TOX)python manage.py waffle_flag enable_learner_analytics --create --everyone -endif ifeq ("${ENABLE_COURSE_LIST_FILTERS}", "True") $(TOX)python manage.py waffle_switch enable_course_filters on --create endif @@ -102,9 +99,6 @@ accept_devstack: # TODO: implement this a11y: -ifeq ("${DISPLAY_LEARNER_ANALYTICS}", "True") - $(TOX)python manage.py waffle_flag enable_learner_analytics --create --everyone -endif cat dashboard.log $(TOX)pytest -v a11y_tests -k 'not NUM_PROCESSES==1' @@ -202,7 +196,7 @@ upgrade: $(COMMON_CONSTRAINTS_TXT) docs: tox -e docs - + install_transifex_client: ## Install the Transifex client curl -o- https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash git checkout -- LICENSE README.md diff --git a/README.md b/README.md index 5250ded1f..dc8d82f39 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,6 @@ functionality on request (e.g. turning on beta functionality for superusers). Cr $ ./manage.py waffle_flag name-of-my-flag --everyone --create -The following flags are available: - -| Flag | Purpose | -|--------------------------------|-------------------------------------------------------| -| display_learner_analytics | Display Learner Analytics links | - - Settings describe features which are not expected to be toggled on and off without significant system changes. The following setting is available: diff --git a/acceptance_tests/__init__.py b/acceptance_tests/__init__.py index 7e9415c20..98bd57543 100644 --- a/acceptance_tests/__init__.py +++ b/acceptance_tests/__init__.py @@ -76,9 +76,6 @@ def str2bool(s): # Video preview ENABLE_VIDEO_PREVIEW = str2bool(os.environ.get('ENABLE_VIDEO_PREVIEW', False)) -# Learner analytics -DISPLAY_LEARNER_ANALYTICS = str2bool(os.environ.get('DISPLAY_LEARNER_ANALYTICS', False)) - # Course listing ENABLE_COURSE_LIST_FILTERS = str2bool(os.environ.get('ENABLE_COURSE_LIST_FILTERS', False)) ENABLE_COURSE_LIST_PASSING = str2bool(os.environ.get('ENABLE_COURSE_LIST_PASSING', False)) diff --git a/acceptance_tests/test_course_learners.py b/acceptance_tests/test_course_learners.py deleted file mode 100644 index 61385ccd1..000000000 --- a/acceptance_tests/test_course_learners.py +++ /dev/null @@ -1,27 +0,0 @@ -from unittest import skipUnless - -from bok_choy.web_app_test import WebAppTest - -from acceptance_tests import DISPLAY_LEARNER_ANALYTICS -from acceptance_tests.mixins import CoursePageTestsMixin -from acceptance_tests.pages import CourseLearnersPage - - -@skipUnless(DISPLAY_LEARNER_ANALYTICS, 'Learner Analytics must be enabled to run CourseLearnersTests') -class CourseLearnersTests(CoursePageTestsMixin, WebAppTest): - test_skip_link_url = False - help_path = 'learners/Learner_Activity.html' - - def setUp(self): - super().setUp() - self.page = CourseLearnersPage(self.browser) - - def _test_data_update_message(self): - # Don't test the update message for now, since it won't exist - # until the SPA adds it to the page in AN-6205. - pass - - def _get_data_update_message(self): - # Don't test the update message for now, since it won't exist - # until the SPA adds it to the page in AN-6205. - return '' diff --git a/analytics_dashboard/courses/templates/courses/learners.html b/analytics_dashboard/courses/templates/courses/learners.html deleted file mode 100644 index fe0140de2..000000000 --- a/analytics_dashboard/courses/templates/courses/learners.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "courses/base-course.html" %} - -{% load dashboard_extras %} -{% load i18n %} -{% load static %} -{% load render_bundle from webpack_loader %} -{% load get_files from webpack_loader %} - -{% comment %} -View of individual learners within a course. -{% endcomment %} - -{% block view-name %}view-learners view-dashboard{% endblock view-name %} - -{% block stylesheets %} - {{ block.super }} - {% get_files 'learners-main' 'css' as common_css %} - {% for css_file in common_css %} - - {% endfor %} -{% endblock stylesheets %} - -{% block javascript %} - {{ block.super }} - {% render_bundle 'learners-main' %} -{% endblock javascript %} - -{% block child_content %} -
-
- {% include "loading.html" %} -
-
-{% endblock %} diff --git a/analytics_dashboard/courses/tests/test_views/test_learners.py b/analytics_dashboard/courses/tests/test_views/test_learners.py deleted file mode 100644 index 6137affaa..000000000 --- a/analytics_dashboard/courses/tests/test_views/test_learners.py +++ /dev/null @@ -1,135 +0,0 @@ -import json -import logging - -import unittest.mock as mock -import httpretty -from ddt import data, ddt -from django.conf import settings -from django.test import TestCase -from django.test.utils import override_settings -from edx_toggles.toggles.testutils import override_waffle_flag -from requests.exceptions import ConnectionError as RequestsConnectionError -from requests.exceptions import Timeout -from testfixtures import LogCapture -from waffle.testutils import override_switch - -from analytics_dashboard.courses.tests.test_views import ViewTestMixin -from analytics_dashboard.courses.tests.utils import assert_dict_contains_subset, CourseSamples -from analytics_dashboard.courses.waffle import ( - DISPLAY_LEARNER_ANALYTICS, -) - - -@httpretty.activate -@override_waffle_flag(DISPLAY_LEARNER_ANALYTICS, active=True) -@ddt -class LearnersViewTests(ViewTestMixin, TestCase): - TABLE_ERROR_TEXT = 'We are unable to load this table.' - viewname = 'courses:learners:learners' - - def _register_uris(self, learners_status, learners_payload, course_metadata_status, course_metadata_payload): - httpretty.register_uri( - httpretty.GET, - f'{settings.DATA_API_URL}/learners/', - body=json.dumps(learners_payload), - status=learners_status - ) - httpretty.register_uri( - httpretty.GET, - '{data_api_url}/course_learner_metadata/{course_id}/'.format( - data_api_url=settings.DATA_API_URL, - course_id=CourseSamples.DEMO_COURSE_ID, - ), - body=json.dumps(course_metadata_payload), - status=course_metadata_status - ) - self.addCleanup(httpretty.reset) - - def _get(self): - return self.client.get(self.path(course_id=CourseSamples.DEMO_COURSE_ID)) - - def _assert_context(self, response, expected_context_subset): - default_expected_context_subset = { - 'learner_list_url': '/api/learner_analytics/v0/learners/', - 'course_learner_metadata_url': '/api/learner_analytics/v0/course_learner_metadata/{course_id}/'.format( - course_id=CourseSamples.DEMO_COURSE_ID - ), - } - assert_dict_contains_subset(response.context, dict(list(expected_context_subset.items()))) - assert_dict_contains_subset( - response.context['js_data']['course'], - dict(list(default_expected_context_subset.items())), - ) - - def get_mock_data(self, *args, **kwargs): - pass - - def test_success(self): - learners_payload = {'arbitrary_learners_key': ['arbitrary_value_1', 'arbitrary_value_2']} - course_metadata_payload = {'arbitrary_metadata_value': {'arbitrary_value_1': 'arbitrary_value_2'}} - self._register_uris(200, learners_payload, 200, course_metadata_payload) - response = self._get() - self._assert_context(response, { - 'learner_list_json': learners_payload, - 'course_learner_metadata_json': course_metadata_payload - }) - self.assertNotContains(response, self.TABLE_ERROR_TEXT) - return response - - @override_waffle_flag(DISPLAY_LEARNER_ANALYTICS, active=False) - def test_redirect_if_disabled(self): - learners_payload = {'arbitrary_learners_key': ['arbitrary_value_1', 'arbitrary_value_2']} - course_metadata_payload = {'arbitrary_metadata_value': {'arbitrary_value_1': 'arbitrary_value_2'}} - self._register_uris(200, learners_payload, 200, course_metadata_payload) - response = self._get() - self.assertRedirects(response, '/courses', target_status_code=301) - - @override_switch('enable_learner_download', active=False) - def test_disable_learner_download_button(self): - response = self.test_success() - self.assertNotIn('learner_list_download_url', response.context['js_data']['course']) - - @override_switch('enable_learner_download', active=True) - @override_settings(LEARNER_API_LIST_DOWNLOAD_FIELDS=None) - def test_enable_learner_download_button(self): - response = self.test_success() - self.assertEqual(response.context['js_data']['course']['learner_list_download_url'], - '/api/learner_analytics/v0/learners.csv') - - @override_switch('enable_learner_download', active=True) - @override_settings(LEARNER_API_LIST_DOWNLOAD_FIELDS='username,email') - def test_enable_learner_download_button_with_fields(self): - response = self.test_success() - self.assertEqual(response.context['js_data']['course']['learner_list_download_url'], - '/api/learner_analytics/v0/learners.csv?fields=username%2Cemail') - - @data(Timeout, RequestsConnectionError, ValueError) - def test_data_api_error(self, RequestExceptionClass): - learners_payload = {'should_not': 'return this value'} - course_metadata_payload = learners_payload - self._register_uris(200, learners_payload, 200, course_metadata_payload) - - # False positive https://github.com/PyCQA/pylint/issues/289 - # pylint: disable=bad-continuation - with mock.patch( - 'analytics_dashboard.learner_analytics_api.v0.clients.LearnerApiResource.get', - mock.Mock(side_effect=RequestExceptionClass), - ): - with LogCapture(level=logging.ERROR) as lc: - response = self._get() - self._assert_context(response, { - 'learner_list_json': 'Failed to reach the Learner List endpoint', - 'course_learner_metadata_json': 'Failed to reach the Course Learner Metadata endpoint' - }) - lc.check( - ( - 'analytics_dashboard.courses.views.learners', - 'ERROR', - 'Failed to reach the Learner List endpoint', - ), - ( - 'analytics_dashboard.courses.views.learners', - 'ERROR', - 'Failed to reach the Course Learner Metadata endpoint', - ), - ) diff --git a/analytics_dashboard/courses/urls.py b/analytics_dashboard/courses/urls.py index b45deb565..220d0179a 100644 --- a/analytics_dashboard/courses/urls.py +++ b/analytics_dashboard/courses/urls.py @@ -10,7 +10,6 @@ csv, engagement, enrollment, - learners, performance, ) @@ -120,10 +119,6 @@ url(r'problem_responses/', csv.PerformanceProblemResponseCSV.as_view(), name='performance_problem_responses') ], 'csv') -LEARNER_URLS = ([ - url(r'^$', learners.LearnersView.as_view(), name='learners'), -], 'learners') - COURSE_URLS = [ # Course homepage. This should be the entry point for other applications linking to the course. url(r'^$', views.CourseHome.as_view(), name='home'), @@ -131,7 +126,6 @@ url(r'^engagement/', include(ENGAGEMENT_URLS)), url(r'^performance/', include(PERFORMANCE_URLS)), url(r'^csv/', include(CSV_URLS)), - url(r'^learners/', include(LEARNER_URLS)), ] app_name = 'courses' diff --git a/analytics_dashboard/courses/views/__init__.py b/analytics_dashboard/courses/views/__init__.py index 9457f68ca..ad5136bd1 100644 --- a/analytics_dashboard/courses/views/__init__.py +++ b/analytics_dashboard/courses/views/__init__.py @@ -35,9 +35,7 @@ from analytics_dashboard.courses.presenters.performance import CourseReportDownloadPresenter from analytics_dashboard.courses.serializers import LazyEncoder from analytics_dashboard.courses.utils import get_page_name, is_feature_enabled -from analytics_dashboard.courses.waffle import ( - DISPLAY_LEARNER_ANALYTICS, age_available, -) +from analytics_dashboard.courses.waffle import age_available from analytics_dashboard.help.views import ContextSensitiveHelpMixin logger = logging.getLogger(__name__) @@ -337,18 +335,6 @@ def get_primary_nav_items(self, request): 'lens': 'performance', 'report': 'graded', 'depth': '' - }, - { - 'name': 'learners', - 'text': ugettext_noop('Learners'), - 'view': 'courses:learners:learners', - 'icon': 'fa-users', - 'flag': 'display_learner_analytics', - 'fragment': '#?ignore_segments=inactive', - 'scope': 'course', - 'lens': 'learners', - 'report': 'roster', - 'depth': '' } ] @@ -692,44 +678,6 @@ def get_table_items(self): 'items': subitems }) - if DISPLAY_LEARNER_ANALYTICS.is_enabled(): - items.append({ - 'name': _('Learners'), - 'icon': 'fa-users', - 'heading': _('What are individual learners doing?'), - 'items': [ - { - 'title': ugettext_noop("Who is engaged? Who isn't?"), - 'view': 'courses:learners:learners', - 'breadcrumbs': [_('All Learners')], - 'fragment': '#?ignore_segments=inactive', - 'scope': 'course', - 'lens': 'learners', - 'report': 'roster', - 'depth': '' - }, - # TODO: this is commented out until we complete the deep linking work, AN-6671 - # { - # 'title': _('Who has been active recently?'), - # 'view': 'courses:learners:learners', # TODO: map this to the actual action in AN-6205 - # # TODO: what would the breadcrumbs be? - # 'breadcrumbs': [_('Learners')] - # }, - # { - # 'title': _('Who is most engaged in the discussions?'), - # 'view': 'courses:learners:learners', # TODO: map this to the actual action in AN-6205 - # # TODO: what would the breadcrumbs be? - # 'breadcrumbs': [_('Learners')] - # }, - # { - # 'title': _("Who hasn't watched videos recently?"), - # 'view': 'courses:learners:learners', # TODO: map this to the actual action in AN-6205 - # # TODO: what would the breadcrumbs be? - # 'breadcrumbs': [_('Learners')] - # } - ] - }) - translate_dict_values(items, ('name',)) for item in items: translate_dict_values(item['items'], ('title',)) @@ -745,22 +693,6 @@ def get_context_data(self, **kwargs): context['page_data'] = self.get_page_data(context) - # Some orgs do not wish to allow access to learner analytics. - # See https://openedx.atlassian.net/browse/DENG-536 - course_org = CourseKey.from_string(self.course_id).org - if course_org in settings.BLOCK_LEARNER_ANALYTICS_ORG_LIST: - user = self.request.user.get_username() - logger.info( - 'Removing learner analytics from the %s course home page user %s', - self.course_id, user - ) - context['primary_nav_items'] = [ - item for item in context['primary_nav_items'] if item['name'] != 'learners' - ] - context['table_items'] = [ - item for item in context['table_items'] if item['name'] != _('Learners') - ] - overview_data = [] if self.course_api_enabled: if switch_is_active('display_course_name_in_nav'): diff --git a/analytics_dashboard/courses/views/learners.py b/analytics_dashboard/courses/views/learners.py deleted file mode 100644 index 239823e21..000000000 --- a/analytics_dashboard/courses/views/learners.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging - -from urllib.parse import urlencode -from django.conf import settings -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ -from requests.exceptions import ConnectionError as RequestsConnectionError -from requests.exceptions import Timeout -from waffle import switch_is_active - -from analytics_dashboard.courses.views import CourseTemplateWithNavView, AnalyticsV0Mixin -from analytics_dashboard.courses.waffle import DISPLAY_LEARNER_ANALYTICS -from analytics_dashboard.learner_analytics_api.v0.clients import LearnerAPIClient - -logger = logging.getLogger(__name__) - - -class LearnersView(AnalyticsV0Mixin, CourseTemplateWithNavView): - template_name = 'courses/learners.html' - active_primary_nav_item = 'learners' - page_title = _('Learners') - page_name = { - 'scope': 'course', - 'lens': 'learners', - 'report': 'roster', - 'depth': '' - } - - def dispatch(self, request, *args, **kwargs): - if DISPLAY_LEARNER_ANALYTICS.is_enabled(): - return super().dispatch(request, *args, **kwargs) - return redirect('/courses') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['js_data']['course'].update({ - 'learner_list_url': reverse('learner_analytics_api:v0:LearnerList'), - 'course_learner_metadata_url': reverse( - 'learner_analytics_api:v0:CourseMetadata', - args=(self.course_id,) - ), - 'learner_engagement_timeline_url': reverse( - 'learner_analytics_api:v0:EngagementTimeline', - # Unfortunately, we need to pass a username to the `reverse` - # function. This will get dynamically interpolated with the - # actual users' usernames on the client side. - kwargs={'username': 'temporary_username'} - ), - }) - - # Try to prefetch API responses. If anything fails, the front-end will - # retry the requests and gracefully fail. - client = LearnerAPIClient() - for data_name, request_function, error_message in [ - ( - 'learner_list_json', - lambda: client.learners.get(course_id=self.course_id).json(), - 'Failed to reach the Learner List endpoint', - ), - ( - 'course_learner_metadata_json', - lambda: client.course_learner_metadata(self.course_id).get().json(), - 'Failed to reach the Course Learner Metadata endpoint', - ) - ]: - try: - context[data_name] = request_function() - except (Timeout, RequestsConnectionError, ValueError): - # ValueError may be thrown by the call to .json() - logger.exception(error_message) - context[data_name] = error_message - context['js_data']['course'].update({ - data_name: context[data_name] - }) - - # Only show learner download button(s) if switch is enabled - if switch_is_active('enable_learner_download'): - list_download_url = reverse('learner_analytics_api:v0:LearnerListCSV') - - # Append the 'fields' parameter if configured - list_fields = getattr(settings, 'LEARNER_API_LIST_DOWNLOAD_FIELDS', None) - if list_fields is not None: - list_download_url = '{}?{}'.format(list_download_url, - urlencode(dict(fields=list_fields))) - context['js_data']['course'].update({ - 'learner_list_download_url': list_download_url, - }) - - context['page_data'] = self.get_page_data(context) - return context diff --git a/analytics_dashboard/courses/waffle.py b/analytics_dashboard/courses/waffle.py index b68d03ac7..1564818f6 100644 --- a/analytics_dashboard/courses/waffle.py +++ b/analytics_dashboard/courses/waffle.py @@ -4,22 +4,7 @@ """ -from edx_toggles.toggles import NonNamespacedWaffleFlag, SettingToggle - -# .. toggle_name: display_learner_analytics -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Displays the Learner Analytics tab and links to Learner Analytics. Learner Analytics helps -# identify which learners are active and engaged and which aren't. It also provides an overview of their daily -# activity and their enrollment in a course. This was a rollout flag and we recommend you enable this feature. -# .. toggle_warning: Requires the `ModuleEngagementWorkflowTask` to be run to populate the charts. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2016-04-15 -# .. toggle_target_removal_date: 2016-07-15 -# .. toggle_tickets: https://github.com/edx/edx-analytics-dashboard/pull/440, -# https://github.com/edx/edx-analytics-dashboard/pull/522 -DISPLAY_LEARNER_ANALYTICS = NonNamespacedWaffleFlag( - 'display_learner_analytics', __name__) +from edx_toggles.toggles import SettingToggle # .. toggle_name: ENROLLMENT_AGE_AVAILABLE # .. toggle_implementation: SettingToggle diff --git a/analytics_dashboard/learner_analytics_api/__init__.py b/analytics_dashboard/learner_analytics_api/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/analytics_dashboard/learner_analytics_api/urls.py b/analytics_dashboard/learner_analytics_api/urls.py deleted file mode 100644 index 7eea709a1..000000000 --- a/analytics_dashboard/learner_analytics_api/urls.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.conf.urls import include, url - -app_name = 'learner_analytics_api' -urlpatterns = [ - url(r'^v0/', include('learner_analytics_api.v0.urls')) -] diff --git a/analytics_dashboard/learner_analytics_api/v0/__init__.py b/analytics_dashboard/learner_analytics_api/v0/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/analytics_dashboard/learner_analytics_api/v0/clients.py b/analytics_dashboard/learner_analytics_api/v0/clients.py deleted file mode 100644 index 6faf21e8a..000000000 --- a/analytics_dashboard/learner_analytics_api/v0/clients.py +++ /dev/null @@ -1,71 +0,0 @@ -import requests -from django.conf import settings -from slumber import API, Resource, exceptions, serialize - - -class TokenAuth(requests.auth.AuthBase): - """A requests auth class for DRF-style token-based authentication""" - def __init__(self, token): - self.token = token - - def __call__(self, r): - r.headers['Authorization'] = f'Token {self.token}' - return r - - -class TextSerializer(serialize.BaseSerializer): - """ - Slumber API Serializer for text data, e.g. CSV. - """ - key = 'text' - content_types = ('text/csv', 'text/plain', ) - - def unchanged(self, data): - """Leaves the request/response data unchanged.""" - return data - - # Define the abstract methods from BaseSerializer - dumps = loads = unchanged - - -class LearnerApiResource(Resource): - """ - Overrides slumber's default behavior of hiding the requests library's - response object. This allows us to return responses directly from the - Learner Analytics API to the browser. - """ - def _request(self, *args, **kwargs): - # Doesn't hide 400s and 500s, however timeouts will still - # raise a requests.exceptions.ConnectTimeout. - try: - response = super()._request(*args, **kwargs) - except exceptions.SlumberHttpBaseException as e: - response = e.response - return response - - def _process_response(self, response): - response.serialized_content = self._try_to_serialize_response(response) - return response - - -class LearnerAPIClient(API): - resource_class = LearnerApiResource - - def __init__(self, timeout=5, serializer_type='json'): - session = requests.session() - session.timeout = timeout - - serializers = serialize.Serializer( - default=serializer_type, - serializers=[ - serialize.JsonSerializer(), - serialize.YamlSerializer(), - TextSerializer(), - ] - ) - super().__init__( - settings.DATA_API_URL, - session=session, - auth=TokenAuth(settings.DATA_API_AUTH_TOKEN), - serializer=serializers, - ) diff --git a/analytics_dashboard/learner_analytics_api/v0/permissions.py b/analytics_dashboard/learner_analytics_api/v0/permissions.py deleted file mode 100644 index 2ead0767d..000000000 --- a/analytics_dashboard/learner_analytics_api/v0/permissions.py +++ /dev/null @@ -1,33 +0,0 @@ -import logging - -from rest_framework.permissions import BasePermission - -from analytics_dashboard.courses.exceptions import PermissionsRetrievalFailedError -from analytics_dashboard.courses.permissions import user_can_view_course - -logger = logging.getLogger(__name__) - - -def user_can_view_learners_in_course(user, course_id): - """ - Returns whether or not a user can access a particular course within the - learner API. - """ - try: - user_has_permission = user_can_view_course(user, course_id) - except PermissionsRetrievalFailedError: - logger.exception( - "Unable to retrieve course permissions for username=%s in v0", - user.username, - ) - user_has_permission = False - return user_has_permission - - -class HasCourseAccessPermission(BasePermission): - """ - Enforces that the requesting user has course access permissions. Requires - that view classes have a course_id property for the requested course_id. - """ - def has_permission(self, request, view): - return user_can_view_learners_in_course(request.user, view.course_id) diff --git a/analytics_dashboard/learner_analytics_api/v0/renderers.py b/analytics_dashboard/learner_analytics_api/v0/renderers.py deleted file mode 100644 index 13192a41c..000000000 --- a/analytics_dashboard/learner_analytics_api/v0/renderers.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Custom Django REST Framework rendererers used by the learner analytics API views. -""" - -from rest_framework.renderers import BaseRenderer - - -class TextRenderer(BaseRenderer): - """Renders the REST response data without modification.""" - - def render(self, data, *_args, **_kwargs): - """Return the data unchanged.""" - return data diff --git a/analytics_dashboard/learner_analytics_api/v0/tests/__init__.py b/analytics_dashboard/learner_analytics_api/v0/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/analytics_dashboard/learner_analytics_api/v0/tests/test_views.py b/analytics_dashboard/learner_analytics_api/v0/tests/test_views.py deleted file mode 100644 index 60f17e992..000000000 --- a/analytics_dashboard/learner_analytics_api/v0/tests/test_views.py +++ /dev/null @@ -1,154 +0,0 @@ -import json - -import unittest.mock as mock -import ddt -import httpretty -from django.conf import settings -from django.test import TestCase -from edx_toggles.toggles.testutils import override_waffle_flag -from requests.exceptions import ConnectTimeout - -from analytics_dashboard.core.tests.test_views import UserTestCaseMixin -from analytics_dashboard.courses.tests.test_views import PermissionsTestMixin -from analytics_dashboard.courses.waffle import ( - DISPLAY_LEARNER_ANALYTICS, -) - - -@ddt.ddt -class LearnerAPITestMixin(UserTestCaseMixin, PermissionsTestMixin): - """ - Provides test cases and helper methods for learner analytics api test - classes. - - Subclasses must override the following properties: - - - endpoint (str): the part of the url following - '/api/learner_analytics/v0' which identifies the resource, e.g. - '/learners/' - - required_query_params (dict): a dict of querystring parameter keys - and values which are required to make a valid request against the - endpoint - - no_permissions_status_code (int): the status code expected to be - returned when the user is authenticated but does not have permission - to access the endpoint - """ - endpoint = '' - required_query_params = {} - no_permissions_status_code = None - content_type = 'application/json' - - @property - def remote_endpoint(self): - '''By default, the remote API endpoint matches the local API endpoint.''' - return self.endpoint - - def assert_response_equals(self, response, expected_status_code, expected_body=None): - self.assertEqual(response.status_code, expected_status_code) - if expected_body is not None: - self.assertEqual(json.loads(response.content.decode()), expected_body) - - def test_not_authenticated(self): - response = self.client.get('/api/learner_analytics/v0' + self.endpoint, self.required_query_params) - self.assert_response_equals(response, 403, {'detail': 'Authentication credentials were not provided.'}) - - def test_no_course_permissions(self): - self.login() - self.grant_permission(self.user, []) - response = self.client.get('/api/learner_analytics/v0' + self.endpoint, self.required_query_params) - self.assert_response_equals(response, self.no_permissions_status_code) - - @mock.patch('learner_analytics_api.v0.clients.LearnerApiResource._request', mock.Mock(side_effect=ConnectTimeout)) - def test_timeout(self): - self.login() - self.grant_permission(self.user, 'edX/DemoX/Demo_Course') - response = self.client.get('/api/learner_analytics/v0' + self.endpoint, self.required_query_params) - self.assertEqual(response.status_code, 504) - - @ddt.data((200, {'test': 'value'}), (400, {'a': 'b', 'c': 'd'}), (500, {})) - @ddt.unpack - @httpretty.activate - def test_authenticated_and_authorized(self, status_code, body): - self.login() - course_id = 'edX/DemoX/Demo_Course' - self.grant_permission(self.user, course_id) - httpretty.register_uri( - httpretty.GET, settings.DATA_API_URL + self.remote_endpoint, body=json.dumps(body), status=status_code, - content_type=self.content_type, - ) - response = self.client.get('/api/learner_analytics/v0' + self.endpoint, self.required_query_params) - self.assert_response_equals(response, status_code, body) - - -@override_waffle_flag(DISPLAY_LEARNER_ANALYTICS, active=True) -class LearnerDetailViewTestCase(LearnerAPITestMixin, TestCase): - endpoint = '/learners/username/' - required_query_params = {'course_id': 'edX/DemoX/Demo_Course'} - no_permissions_status_code = 404 - - def test_no_course_id_provided(self): - self.login() - self.grant_permission(self.user, 'edX/DemoX/Demo_Course') - response = self.client.get('/api/learner_analytics/v0/learners/username/') - self.assert_response_equals(response, 404, { - 'developer_message': 'Learner username not found.', 'error_code': 'no_learner_for_course' - }) - - -@override_waffle_flag(DISPLAY_LEARNER_ANALYTICS, active=True) -class LearnerListViewTestCase(LearnerAPITestMixin, TestCase): - endpoint = '/learners/' - required_query_params = {'course_id': 'edX/DemoX/Demo_Course'} - no_permissions_status_code = 403 - - def test_no_course_id_provided(self): - self.login() - self.grant_permission(self.user, 'edX/DemoX/Demo_Course') - response = self.client.get('/api/learner_analytics/v0/learners/') - self.assert_response_equals(response, 403, {'detail': 'You do not have permission to perform this action.'}) - - -@ddt.ddt -class LearnerListCSVTestCase(LearnerListViewTestCase): - endpoint = '/learners.csv' - remote_endpoint = '/learners/' - content_type = 'text/csv' - course_id = 'edX/DemoX/Demo_Course' - - @httpretty.activate - def test_headers(self): - self.login() - self.grant_permission(self.user, self.course_id) - - # Ensure the extra headers from the remote endpoint get passed through to the response. - content_disposition = 'attachment; filename=learners.csv' - httpretty.register_uri( - httpretty.GET, settings.DATA_API_URL + self.remote_endpoint, body='body', - status=200, content_type=self.content_type, adding_headers={ - 'Content-Disposition': content_disposition, - }, - ) - response = self.client.get('/api/learner_analytics/v0' + self.endpoint, self.required_query_params) - self.assertEqual(response['Content-Disposition'], content_disposition) - - -@override_waffle_flag(DISPLAY_LEARNER_ANALYTICS, active=True) -class EngagementTimelinesViewTestCase(LearnerAPITestMixin, TestCase): - endpoint = '/engagement_timelines/username/' - required_query_params = {'course_id': 'edX/DemoX/Demo_Course'} - no_permissions_status_code = 404 - - def test_no_course_id_provided(self): - self.login() - self.grant_permission(self.user, 'edX/DemoX/Demo_Course') - response = self.client.get('/api/learner_analytics/v0/engagement_timelines/username/') - self.assert_response_equals(response, 404, { - 'developer_message': 'Learner username engagement timeline not found.', - 'error_code': 'no_learner_engagement_timeline' - }) - - -@override_waffle_flag(DISPLAY_LEARNER_ANALYTICS, active=True) -class CourseLearnerMetadataViewTestCase(LearnerAPITestMixin, TestCase): - endpoint = '/course_learner_metadata/edX/DemoX/Demo_Course/' - no_permissions_status_code = 403 diff --git a/analytics_dashboard/learner_analytics_api/v0/urls.py b/analytics_dashboard/learner_analytics_api/v0/urls.py deleted file mode 100644 index 890e1af72..000000000 --- a/analytics_dashboard/learner_analytics_api/v0/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.conf import settings -from django.conf.urls import url - -from . import views - -USERNAME_PATTERN = r'(?P.+)' - -app_name = 'v0' -urlpatterns = [ - url(fr'^learners/{USERNAME_PATTERN}/$', views.LearnerDetailView.as_view(), name='LearnerDetail'), - url(r'^learners/$', views.LearnerListView.as_view(), name='LearnerList'), - url(r'^learners.csv$', views.LearnerListCSV.as_view(), name='LearnerListCSV'), - url(fr'^engagement_timelines/{USERNAME_PATTERN}/$', - views.EngagementTimelinesView.as_view(), - name='EngagementTimeline'), - url(fr'^course_learner_metadata/{settings.COURSE_ID_PATTERN}/$', - views.CourseLearnerMetadataView.as_view(), - name='CourseMetadata'), -] diff --git a/analytics_dashboard/learner_analytics_api/v0/views.py b/analytics_dashboard/learner_analytics_api/v0/views.py deleted file mode 100644 index d1a3ee9e2..000000000 --- a/analytics_dashboard/learner_analytics_api/v0/views.py +++ /dev/null @@ -1,172 +0,0 @@ -from requests.exceptions import ConnectTimeout -from rest_framework.exceptions import PermissionDenied -from rest_framework.generics import RetrieveAPIView -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response - -from .clients import LearnerAPIClient -from .permissions import HasCourseAccessPermission -from .renderers import TextRenderer - - -# TODO: Consider caching responses from the data api when working on AN-6157 -class BaseLearnerApiView(RetrieveAPIView): - permission_classes = (IsAuthenticated, HasCourseAccessPermission,) - - # Serialize the the Learner Analytics API response to JSON, by default. - serializer_type = 'json' - - # Do not return the HTTP headers from the Data API, by default. - # This will be further investigated in AN-6928. - include_headers = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.client = LearnerAPIClient(serializer_type=self.serializer_type) - - def get_queryset(self): - """ - DRF requires that we override this method. Since we don't actually use - querysets/django models in this API, this method doesn't have to return - anything. - """ - - @property - def course_id(self): - """ - Gets the course_id either from the URL or the querystring parameters. - """ - course_id = getattr(self.request, 'course_id') - if not course_id: - course_id = self.request.query_params.get('course_id') - return course_id - - def get(self, request, *args, **kwargs): - """ - Return the response from the Data API. - """ - api_response = self.get_api_response(request, *args, **kwargs) - response_kwargs = dict( - data=api_response.serialized_content, - status=api_response.status_code, - ) - if self.include_headers: - response_kwargs['headers'] = api_response.headers - return Response(**response_kwargs) - - def get_api_response(self, request, *args, **kwargs): - """ - Fetch the response from the API. - - Must be implemented by subclasses. - """ - raise NotImplementedError('Override this method to return the Learner Analytics API response for this view.') - - def handle_exception(self, exc): - """ - Handles timeouts raised by the API client by returning an HTTP - 504. - """ - if isinstance(exc, ConnectTimeout): - return Response( - data={'developer_message': 'Learner Analytics API timed out.', 'error_code': 'analytics_api_timeout'}, - status=504 - ) - return super().handle_exception(exc) - - -class DownloadLearnerApiViewMixin: - """ - Requests text/csv data from the Learner Analytics API, and ensures that the REST framework returns it unparsed, - including the response headers. - """ - include_headers = True - content_type = 'text/csv' - serializer_type = 'text' - - def get_api_response(self, request, **kwargs): - """ - Sets the HTTP_ACCEPT header on the request to tell the Learner Analytics API which format to return its data in. - - And tells the REST framework to render as text. NB: parent class must also define get_api_response() - """ - request.META['Accept'] = self.content_type - request.accepted_renderer = TextRenderer() - return super().get_api_response(request, **kwargs) - - -class NotFoundLearnerApiViewMixin: - """ - Returns 404s rather than 403s when PermissionDenied exceptions are raised. - """ - @property - def not_found_developer_message(self): - raise NotImplementedError('Override this attribute to define the developer message returned with 404s.') - - @property - def not_found_error_code(self): - raise NotImplementedError('Override this attribute to define the error_code string returned with 404s.') - - def handle_exception(self, exc): - if isinstance(exc, PermissionDenied): - return Response( - data={'developer_message': self.not_found_developer_message, 'error_code': self.not_found_error_code}, - status=404 - ) - return super().handle_exception(exc) - - -class LearnerDetailView(NotFoundLearnerApiViewMixin, BaseLearnerApiView): - """ - Forwards requests to the Learner Analytics API's Learner Detail endpoint. - """ - not_found_error_code = 'no_learner_for_course' - - @property - def not_found_developer_message(self): - message = 'Learner {} not found'.format(self.kwargs.get('username', '')) - message += f'for course {self.course_id}.' if self.course_id else '.' - return message - - def get_api_response(self, request, username, **kwargs): - return self.client.learners(username).get(**request.query_params) - - -class LearnerListView(BaseLearnerApiView): - """ - Forwards requests to the Learner Analytics API's Learner List endpoint. - """ - def get_api_response(self, request, **kwargs): - return self.client.learners.get(**request.query_params) - - -class LearnerListCSV(DownloadLearnerApiViewMixin, LearnerListView): - """ - Forwards text/csv requests to the Learner Analytics API's Learner List endpoint, - and returns a simple text response. - """ - - -class EngagementTimelinesView(NotFoundLearnerApiViewMixin, BaseLearnerApiView): - """ - Forwards requests to the Learner Analytics API's Engagement Timeline - endpoint. - """ - not_found_error_code = 'no_learner_engagement_timeline' - - @property - def not_found_developer_message(self): - message = 'Learner {} engagement timeline not found'.format(self.kwargs.get('username', '')) - message += f'for course {self.course_id}.' if self.course_id else '.' - return message - - def get_api_response(self, request, username, **kwargs): - return self.client.engagement_timelines(username).get(**request.query_params) - - -class CourseLearnerMetadataView(BaseLearnerApiView): - """ - Forwards requests to the Learner Analytics API's Course Metadata endpoint. - """ - def get_api_response(self, request, course_id, **kwargs): - return self.client.course_learner_metadata(course_id).get(**request.query_params) diff --git a/analytics_dashboard/settings/base.py b/analytics_dashboard/settings/base.py index a603bfd4d..62b1c161a 100644 --- a/analytics_dashboard/settings/base.py +++ b/analytics_dashboard/settings/base.py @@ -455,17 +455,6 @@ COURSE_ID_PATTERN = r'(?P[^/+]+[/+][^/+]+[/+][^/]+)' ########## END COURSE_ID_PATTERN -########## LEARNER_API_LIST_DOWNLOAD_FIELDS -# Comma-delimited list of field names to include in the Learner List CSV download -# e.g., # "username,segments,cohort,engagements.videos_viewed,last_updated" -# Default (None) includes all available fields, in alphabetical order. -LEARNER_API_LIST_DOWNLOAD_FIELDS = None -########## END LEARNER_API_LIST_DOWNLOAD_FIELDS - -########## BLOCK_LEARNER_ANALYTICS_ORG_LIST -BLOCK_LEARNER_ANALYTICS_ORG_LIST = [] -########## END BLOCK_LEARNER_ANALYTICS_ORG_LIST - ########## CACHE CONFIGURATION # See: https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { diff --git a/analytics_dashboard/urls.py b/analytics_dashboard/urls.py index ef06157ba..d5da27bda 100644 --- a/analytics_dashboard/urls.py +++ b/analytics_dashboard/urls.py @@ -40,10 +40,6 @@ url(r'^announcements/', include('pinax.announcements.urls', namespace='pinax_announcements')), ] -urlpatterns += [ - url(r'^api/learner_analytics/', include('learner_analytics_api.urls')) -] - def debug_page_not_found(request): return defaults.page_not_found(request, AttributeError('foobar'))