From d4eeb912ba4b7f3a509b64b04db8b85097dde3e4 Mon Sep 17 00:00:00 2001 From: andrey-canon Date: Wed, 28 Jun 2023 17:15:15 -0500 Subject: [PATCH] feat: add course list and specific views --- eox_nelp/stats/api/v1/urls.py | 9 +- eox_nelp/stats/api/v1/views.py | 129 ++++++++++++--- eox_nelp/stats/tests/api/v1/tests_views.py | 181 ++++++++++++++++++++- 3 files changed, 283 insertions(+), 36 deletions(-) diff --git a/eox_nelp/stats/api/v1/urls.py b/eox_nelp/stats/api/v1/urls.py index dbbef337..d491d360 100644 --- a/eox_nelp/stats/api/v1/urls.py +++ b/eox_nelp/stats/api/v1/urls.py @@ -1,12 +1,15 @@ """ Course API URLs """ -from django.urls import path +from django.conf import settings +from django.urls import path, re_path -from eox_nelp.stats.api.v1.views import GeneralTenantStatsView +from eox_nelp.stats.api.v1.views import GeneralCourseStatsView, GeneralTenantStatsView app_name = "eox_nelp" # pylint: disable=invalid-name urlpatterns = [ - path('tenant-stats/', GeneralTenantStatsView.as_view(), name="general-stats"), + path('tenant/', GeneralTenantStatsView.as_view(), name="general-stats"), + path('courses/', GeneralCourseStatsView.as_view(), name="courses-stats"), + re_path(rf'^courses/{settings.COURSE_ID_PATTERN}', GeneralCourseStatsView.as_view(), name="course-stats"), ] diff --git a/eox_nelp/stats/api/v1/views.py b/eox_nelp/stats/api/v1/views.py index f3aab485..650cee39 100644 --- a/eox_nelp/stats/api/v1/views.py +++ b/eox_nelp/stats/api/v1/views.py @@ -2,8 +2,10 @@ views: GeneralTenantStatsView: View that handles the general tenant stats. + GeneralTenantCoursesView: View that handles the general courses stats. """ from django.contrib.auth import get_user_model +from django.http import Http404 from rest_framework.response import Response from rest_framework.views import APIView @@ -16,42 +18,28 @@ class GeneralTenantStatsView(APIView): """Class view. Handle general tenant stats. ## Usage - The components key depends on the setting ALLOWED_VERTICAL_BLOCK_TYPES, this should be + The components key depends on the setting API_XBLOCK_TYPES, this should be a list of strings like the following ``` json [" problem", "video", "discussion"] ``` - ### **GET** /eox-nelp/api/stats/v1/tenant-stats/ + ### **GET** /eox-nelp/api/stats/v1/tenant/ **GET Response Values** ``` json { "learners": 1, - "instructors": 1, - "courses": { - "total_courses": 4, - "metrics": [ - { - "id": "course-v1:patata+CS102+2023", - "name": "PROCEDURAL SEDATION AND ANALGESIA COURSE", - "learners": 0, - "instructors": 1, - "sections": 18, - "sub_sections": 144, - "units": 184, - "components": { - "discussion": 0, - "drag-and-drop-v2": 0, - "html": 133, - "openassessment": 0, - "problem": 49, - "video": 0 - } - }, - ... - ] + "courses": 3, + "instructors": 2, + "components": { + "discussion": 0, + "drag-and-drop-v2": 0, + "html": 133, + "openassessment": 0, + "problem": 49, + "video": 0 } } ``` @@ -60,9 +48,98 @@ class GeneralTenantStatsView(APIView): def get(self, request): """Return general tenant stats.""" tenant = request.site.domain + courses = metrics.get_courses_metrics(tenant) + components = {} + + for metric in courses.get("metrics", []): + course_components = metric.get("components", {}) + + for key, value in course_components.items(): + components[key] = components.get(key, 0) + value return Response({ "learners": metrics.get_learners_metric(tenant), - "courses": metrics.get_courses_metrics(tenant), + "courses": courses.get("total_courses", 0), "instructors": metrics.get_instructors_metric(tenant), + "components": components }) + + +class GeneralCourseStatsView(APIView): + """Class view that returns a list of course stats or a specific course stats. + + ## Usage + The components key depends on the setting ALLOWED_VERTICAL_BLOCK_TYPES, this should be + a list of strings like the following + + ``` json + [" problem", "video", "discussion"] + ``` + + ### **GET** /eox-nelp/api/stats/v1/courses/ + + **GET Response Values** + ``` json + { + "total_courses": 4, + "metrics": [ + { + "id": "course-v1:patata+CS102+2023", + "name": "PROCEDURAL SEDATION AND ANALGESIA COURSE", + "learners": 0, + "instructors": 1, + "sections": 18, + "sub_sections": 144, + "units": 184, + "components": { + "discussion": 0, + "drag-and-drop-v2": 0, + "html": 133, + "openassessment": 0, + "problem": 49, + "video": 0 + } + }, + ... + ] + } + ``` + + ### **GET** /eox-nelp/api/stats/v1/courses/course-v1:potato+CS102+2023/ + + **GET Response Values** + ``` json + { + "id": "course-v1:potato+CS102+2023", + "name": "PROCEDURAL SEDATION AND ANALGESIA COURSE", + "learners": 0, + "instructors": 1, + "sections": 18, + "sub_sections": 144, + "units": 184, + "components": { + "discussion": 0, + "drag-and-drop-v2": 0, + "html": 133, + "openassessment": 0, + "problem": 49, + "video": 0 + } + } + ``` + """ + + def get(self, request, course_id=None): + """Return general course stats.""" + tenant = request.site.domain + + if course_id: + courses = metrics.get_cached_courses(tenant) + course = courses.filter(id=course_id).first() + + if not course: + raise Http404 + + return Response(metrics.get_course_metrics(course.id)) + + return Response(metrics.get_courses_metrics(tenant)) diff --git a/eox_nelp/stats/tests/api/v1/tests_views.py b/eox_nelp/stats/tests/api/v1/tests_views.py index 7efba219..4b38bf1b 100644 --- a/eox_nelp/stats/tests/api/v1/tests_views.py +++ b/eox_nelp/stats/tests/api/v1/tests_views.py @@ -2,12 +2,14 @@ Classes: GeneralTenantStatsViewTestCase: Tests cases for GeneralTenantStatsView. + GeneralCourseStatsViewTestCase: Tests cases for GeneralCourseStatsView. """ from ddt import data, ddt from django.contrib.sites.models import Site from django.test import override_settings from django.urls import reverse -from mock import patch +from mock import Mock, patch +from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.test import APITestCase @@ -34,17 +36,53 @@ def test_default(self, mock_metrics): """ mock_metrics.get_learners_metric.return_value = 5 mock_metrics.get_instructors_metric.return_value = 4875 - mock_metrics.get_courses_metrics.return_value = [ - "fake_course_1", - "fake_course_2", - "fake_course_3", - ] + mock_metrics.get_courses_metrics.return_value = { + "total_courses": 3, + "metrics": [ + {}, {}, {} + ], + } url_endpoint = reverse("stats-api:v1:general-stats") response = self.client.get(url_endpoint) self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertTrue(["learners", "courses", "instructors"] == list(response.data.keys())) + self.assertTrue(["learners", "courses", "instructors", "components"] == list(response.data.keys())) + + @override_settings( + MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"], + STATS_SETTINGS={"API_XBLOCK_TYPES": ["html", "problem", "video"]}, + ) + @patch("eox_nelp.stats.api.v1.views.metrics") + def test_total_components(self, mock_metrics): + """ + Test that the view will calculate the total of components based on the metrics values + + Expected behavior: + - Status code 200. + - Components total values are the expected. + """ + fake_metric = { + "components": { + "html": 5, + "problem": 10, + "video": 15, + } + } + mock_metrics.get_learners_metric.return_value = 5 + mock_metrics.get_instructors_metric.return_value = 4875 + mock_metrics.get_courses_metrics.return_value = { + "total_courses": 3, + "metrics": [ + fake_metric, fake_metric, fake_metric, fake_metric + ], + } + url_endpoint = reverse("stats-api:v1:general-stats") + + response = self.client.get(url_endpoint) + + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual({"html": 20, "problem": 40, "video": 60}, response.data["components"]) @override_settings(MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"]) @data("post", "put", "patch", "delete") @@ -61,3 +99,132 @@ def test_invalid_method(self, method): response = request(url_endpoint) self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, response.status_code) + + +@ddt +class GeneralCourseStatsViewTestCase(APITestCase): + """ Test GeneralCourseStatsView.""" + + def setUp(self): + """ + Create site since the view use the request.site attribute to determine the current domain. + """ + Site.objects.get_or_create(domain="testserver") + + @override_settings(MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"]) + @patch("eox_nelp.stats.api.v1.views.metrics") + def test_get_list(self, mock_metrics): + """ + Test a get request, this will verify the standard view behavior by checking the call of the metrics functions. + + Expected behavior: + - Status code 200. + - the total_course key is present and has the expected value + - the lenght of metrics is the expected. + """ + mock_metrics.get_courses_metrics.return_value = { + "total_courses": 3, + "metrics": [ + {}, {}, {} + ], + } + url_endpoint = reverse("stats-api:v1:courses-stats") + + response = self.client.get(url_endpoint) + + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(3, response.data["total_courses"]) + self.assertEqual(3, len(response.data["metrics"])) + + @override_settings(MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"]) + @patch("eox_nelp.stats.api.v1.views.metrics") + def test_get_detail(self, mock_metrics): + """ + Test that a single course stats is returned. + + Expected behavior: + - Status code 200. + - response data is the same as the get_course_metrics result + - get_cached_courses is called with the right parameter. + - filter is called with the right parameter. + - first is called once. + - get_course_metrics is called with the right parameter. + """ + course_id = "course-v1:potato+CS102+2023" + courses_mock = Mock() + course_mock = Mock() + course_mock.id = CourseKey.from_string(course_id) + courses_mock.filter.return_value.first.return_value = course_mock + mock_metrics.get_cached_courses.return_value = courses_mock + mock_metrics.get_course_metrics.return_value = { + "id": course_id, + "name": "PROCEDURAL SEDATION AND ANALGESIA COURSE", + "learners": 0, + "instructors": 1, + "sections": 18, + "sub_sections": 144, + "units": 184, + "components": { + "discussion": 0, + "drag-and-drop-v2": 0, + "html": 133, + "openassessment": 0, + "problem": 49, + "video": 0 + } + } + url_endpoint = reverse("stats-api:v1:course-stats", args=[course_id]) + + response = self.client.get(url_endpoint) + + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(mock_metrics.get_course_metrics.return_value, response.data) + mock_metrics.get_cached_courses.assert_called_once_with("testserver") + courses_mock.filter.assert_called_once_with(id=course_id) + courses_mock.filter.return_value.first.assert_called_once() + mock_metrics.get_course_metrics.assert_called_once_with(course_mock.id) + + @override_settings(MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"]) + @patch("eox_nelp.stats.api.v1.views.metrics") + def test_get_not_found(self, mock_metrics): + """ + Test that a single course stats is returned. + + Expected behavior: + - Status code 200. + - response data is the same as the get_course_metrics result + - get_cached_courses is called with the right parameter. + - filter is called with the right parameter. + - first is called once. + - get_course_metrics is not called. + """ + course_id = "course-v1:potato+CS102+2023" + courses_mock = Mock() + courses_mock.filter.return_value.first.return_value = None + mock_metrics.get_cached_courses.return_value = courses_mock + + url_endpoint = reverse("stats-api:v1:course-stats", args=[course_id]) + + response = self.client.get(url_endpoint) + + self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code) + mock_metrics.get_cached_courses.assert_called_once_with("testserver") + courses_mock.filter.assert_called_once_with(id=course_id) + courses_mock.filter.return_value.first.assert_called_once() + mock_metrics.get_course_metrics.assert_not_called() + + @override_settings(MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"]) + @data("post", "put", "patch", "delete") + def test_invalid_list_method(self, method): + """ + This test that the view returns a method not allowed response, since the get is the unique valid method. + + Expected behavior: + - Status code 405. + """ + url_endpoint = reverse("stats-api:v1:courses-stats") + request = getattr(self.client, method) + + response = request(url_endpoint) + + self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, response.status_code)