From 02c763f0970e15276d3ab2f807a7347b7a26b535 Mon Sep 17 00:00:00 2001 From: andrey-canon Date: Thu, 29 Jun 2023 13:59:28 -0500 Subject: [PATCH] feat: add stats API view --- .../edxapp_wrapper/backends/branding_m_v1.py | 13 + .../edxapp_wrapper/backends/student_m_v1.py | 11 +- eox_nelp/edxapp_wrapper/branding.py | 12 + eox_nelp/edxapp_wrapper/student.py | 1 + .../test_backends/branding_m_v1.py | 10 + .../test_backends/student_m_v1.py | 8 + eox_nelp/settings/common.py | 1 + eox_nelp/settings/test.py | 5 + eox_nelp/stats/__init__.py | 0 eox_nelp/stats/api/__init__.py | 0 eox_nelp/stats/api/urls.py | 9 + eox_nelp/stats/api/v1/__init__.py | 0 eox_nelp/stats/api/v1/urls.py | 15 + eox_nelp/stats/api/v1/views.py | 145 +++++++ eox_nelp/stats/decorators.py | 37 ++ eox_nelp/stats/metrics.py | 136 +++++++ eox_nelp/stats/tests/__init__.py | 0 eox_nelp/stats/tests/api/__init__.py | 0 eox_nelp/stats/tests/api/v1/__init__.py | 0 eox_nelp/stats/tests/api/v1/tests_views.py | 232 +++++++++++ eox_nelp/stats/tests/tests_decorators.py | 58 +++ eox_nelp/stats/tests/tests_metrics.py | 378 ++++++++++++++++++ eox_nelp/tests/utils.py | 16 + eox_nelp/urls.py | 1 + 24 files changed, 1087 insertions(+), 1 deletion(-) create mode 100644 eox_nelp/edxapp_wrapper/backends/branding_m_v1.py create mode 100644 eox_nelp/edxapp_wrapper/branding.py create mode 100644 eox_nelp/edxapp_wrapper/test_backends/branding_m_v1.py create mode 100644 eox_nelp/stats/__init__.py create mode 100644 eox_nelp/stats/api/__init__.py create mode 100644 eox_nelp/stats/api/urls.py create mode 100644 eox_nelp/stats/api/v1/__init__.py create mode 100644 eox_nelp/stats/api/v1/urls.py create mode 100644 eox_nelp/stats/api/v1/views.py create mode 100644 eox_nelp/stats/decorators.py create mode 100644 eox_nelp/stats/metrics.py create mode 100644 eox_nelp/stats/tests/__init__.py create mode 100644 eox_nelp/stats/tests/api/__init__.py create mode 100644 eox_nelp/stats/tests/api/v1/__init__.py create mode 100644 eox_nelp/stats/tests/api/v1/tests_views.py create mode 100644 eox_nelp/stats/tests/tests_decorators.py create mode 100644 eox_nelp/stats/tests/tests_metrics.py diff --git a/eox_nelp/edxapp_wrapper/backends/branding_m_v1.py b/eox_nelp/edxapp_wrapper/backends/branding_m_v1.py new file mode 100644 index 00000000..4cc781a6 --- /dev/null +++ b/eox_nelp/edxapp_wrapper/backends/branding_m_v1.py @@ -0,0 +1,13 @@ +"""Backend for branding django app module. +This file contains all the necessary branding dependencies from +https://github.com/eduNEXT/edunext-platform/blob/ednx-release/mango.master/lms/djangoapps/branding/__init__.py""" +from lms.djangoapps.branding import get_visible_courses + + +def get_visible_courses_method(): + """Allow to get the get_visible_courses function from + https://github.com/eduNEXT/edunext-platform/blob/ednx-release/mango.master/lms/djangoapps/branding/__init__.py#L17 + Returns: + get_visible_courses function. + """ + return get_visible_courses diff --git a/eox_nelp/edxapp_wrapper/backends/student_m_v1.py b/eox_nelp/edxapp_wrapper/backends/student_m_v1.py index ff6e749c..9403b61a 100644 --- a/eox_nelp/edxapp_wrapper/backends/student_m_v1.py +++ b/eox_nelp/edxapp_wrapper/backends/student_m_v1.py @@ -2,7 +2,7 @@ This file contains all the necessary student dependencies from https://github.com/eduNEXT/edunext-platform/tree/ednx-release/mango.master/common/djangoapps/student """ -from common.djangoapps.student.models import CourseEnrollment # pylint: disable=import-error +from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment # pylint: disable=import-error def get_course_enrollment_model(): @@ -12,3 +12,12 @@ def get_course_enrollment_model(): CourseEnrollment Model. """ return CourseEnrollment + + +def get_course_access_role_model(): + """Allow to get the CourseAccessRole Model from + https://github.com/eduNEXT/edunext-platform/blob/ednx-release/mango.master/common/djangoapps/student/models.py#L2554 + Returns: + CourseAccessRole Model. + """ + return CourseAccessRole diff --git a/eox_nelp/edxapp_wrapper/branding.py b/eox_nelp/edxapp_wrapper/branding.py new file mode 100644 index 00000000..91f24524 --- /dev/null +++ b/eox_nelp/edxapp_wrapper/branding.py @@ -0,0 +1,12 @@ +"""Wrapper for module in branding app. +This contains all the required dependencies from branding. +Attributes: + get_visible_courses: Wrapper get_visible_courses function. +""" +from importlib import import_module + +from django.conf import settings + +backend = import_module(settings.EOX_NELP_BRANDING_BACKEND) + +get_visible_courses = backend.get_visible_courses_method() diff --git a/eox_nelp/edxapp_wrapper/student.py b/eox_nelp/edxapp_wrapper/student.py index bbaf693b..e1df58cf 100644 --- a/eox_nelp/edxapp_wrapper/student.py +++ b/eox_nelp/edxapp_wrapper/student.py @@ -11,3 +11,4 @@ backend = import_module(settings.EOX_NELP_STUDENT_BACKEND) CourseEnrollment = backend.get_course_enrollment_model() +CourseAccessRole = backend.get_course_access_role_model() diff --git a/eox_nelp/edxapp_wrapper/test_backends/branding_m_v1.py b/eox_nelp/edxapp_wrapper/test_backends/branding_m_v1.py new file mode 100644 index 00000000..12476c1d --- /dev/null +++ b/eox_nelp/edxapp_wrapper/test_backends/branding_m_v1.py @@ -0,0 +1,10 @@ +"""Test backend for bulk-email module.""" +from mock import Mock + + +def get_visible_courses_method(): + """Return test function. + Returns: + Mock class. + """ + return Mock() diff --git a/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py b/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py index 11425aa4..b07b1b7c 100644 --- a/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py +++ b/eox_nelp/edxapp_wrapper/test_backends/student_m_v1.py @@ -8,3 +8,11 @@ def get_course_enrollment_model(): Mock class. """ return Mock() + + +def get_course_access_role_model(): + """Return test Model. + Returns: + Mock class. + """ + return Mock() diff --git a/eox_nelp/settings/common.py b/eox_nelp/settings/common.py index 91c30d71..0af253fe 100644 --- a/eox_nelp/settings/common.py +++ b/eox_nelp/settings/common.py @@ -43,6 +43,7 @@ def plugin_settings(settings): settings.EOX_NELP_BULK_EMAIL_BACKEND = 'eox_nelp.edxapp_wrapper.backends.bulk_email_m_v1' settings.EOX_NELP_STUDENT_BACKEND = 'eox_nelp.edxapp_wrapper.backends.student_m_v1' settings.EOX_NELP_EDXMAKO_BACKEND = 'eox_nelp.edxapp_wrapper.backends.edxmako_m_v1' + settings.EOX_NELP_BRANDING_BACKEND = 'eox_nelp.edxapp_wrapper.backends.branding_m_v1' settings.FUTUREX_API_URL = 'https://testing-site.com' settings.FUTUREX_API_CLIENT_ID = 'my-test-client-id' diff --git a/eox_nelp/settings/test.py b/eox_nelp/settings/test.py index 149b99b0..7893386f 100644 --- a/eox_nelp/settings/test.py +++ b/eox_nelp/settings/test.py @@ -30,6 +30,7 @@ def plugin_settings(settings): # pylint: disable=function-redefined settings.EOX_NELP_BULK_EMAIL_BACKEND = 'eox_nelp.edxapp_wrapper.test_backends.bulk_email_m_v1' settings.EOX_NELP_STUDENT_BACKEND = 'eox_nelp.edxapp_wrapper.test_backends.student_m_v1' settings.EOX_NELP_EDXMAKO_BACKEND = 'eox_nelp.edxapp_wrapper.test_backends.edxmako_m_v1' + settings.EOX_NELP_BRANDING_BACKEND = 'eox_nelp.edxapp_wrapper.test_backends.branding_m_v1' settings.FUTUREX_API_URL = 'https://testing.com' settings.FUTUREX_API_CLIENT_ID = 'my-test-client-id' @@ -94,6 +95,10 @@ def plugin_settings(settings): # pylint: disable=function-redefined EOX_CORE_COURSEWARE_BACKEND = "eox_nelp.edxapp_wrapper.test_backends.courseware_m_v1" EOX_CORE_GRADES_BACKEND = "eox_nelp.edxapp_wrapper.test_backends.grades_m_v1" +GET_SITE_CONFIGURATION_MODULE = 'eox_tenant.edxapp_wrapper.backends.site_configuration_module_test_v1' +GET_THEMING_HELPERS = 'eox_tenant.edxapp_wrapper.backends.theming_helpers_test_v1' +EOX_TENANT_USERS_BACKEND = 'eox_tenant.edxapp_wrapper.backends.users_test_v1' + # ------------eox-audit external config for tests------------------------------ if find_spec('eox_audit_model') and EOX_AUDIT_MODEL_APP not in INSTALLED_APPS: # noqa: F405 INSTALLED_APPS.append(EOX_AUDIT_MODEL_APP) # noqa: F405 diff --git a/eox_nelp/stats/__init__.py b/eox_nelp/stats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eox_nelp/stats/api/__init__.py b/eox_nelp/stats/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eox_nelp/stats/api/urls.py b/eox_nelp/stats/api/urls.py new file mode 100644 index 00000000..6e8a4fd7 --- /dev/null +++ b/eox_nelp/stats/api/urls.py @@ -0,0 +1,9 @@ +"""eox_nelp course_experience api urls +""" +from django.urls import include, path + +app_name = "eox_nelp" # pylint: disable=invalid-name + +urlpatterns = [ # pylint: disable=invalid-name + path("v1/", include("eox_nelp.stats.api.v1.urls", namespace="v1")), +] diff --git a/eox_nelp/stats/api/v1/__init__.py b/eox_nelp/stats/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eox_nelp/stats/api/v1/urls.py b/eox_nelp/stats/api/v1/urls.py new file mode 100644 index 00000000..d491d360 --- /dev/null +++ b/eox_nelp/stats/api/v1/urls.py @@ -0,0 +1,15 @@ +""" +Course API URLs +""" +from django.conf import settings +from django.urls import path, re_path + +from eox_nelp.stats.api.v1.views import GeneralCourseStatsView, GeneralTenantStatsView + +app_name = "eox_nelp" # pylint: disable=invalid-name + +urlpatterns = [ + 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 new file mode 100644 index 00000000..650cee39 --- /dev/null +++ b/eox_nelp/stats/api/v1/views.py @@ -0,0 +1,145 @@ +"""Stats API v1 view file. + +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 + +from eox_nelp.stats import metrics + +User = get_user_model() + + +class GeneralTenantStatsView(APIView): + """Class view. Handle general tenant stats. + + ## Usage + 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/ + + **GET Response Values** + ``` json + { + "learners": 1, + "courses": 3, + "instructors": 2, + "components": { + "discussion": 0, + "drag-and-drop-v2": 0, + "html": 133, + "openassessment": 0, + "problem": 49, + "video": 0 + } + } + ``` + """ + + 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": 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/decorators.py b/eox_nelp/stats/decorators.py new file mode 100644 index 00000000..b38acabe --- /dev/null +++ b/eox_nelp/stats/decorators.py @@ -0,0 +1,37 @@ +"""eox-nelp Metrics file. + +decorators: + cache_method: Cache the result of the inner method. +""" +from django.conf import settings +from django.core.cache import cache + + +def cache_method(func): + """ + Cache the function result to improve the response time. + + Args: + func: Target function to be cached. + + Return: + : Wrapper function. + """ + def wrapper(*args, **kwargs): + key = f"{func.__name__}.{'-'.join(map(str, args))}.STATS_CACHE_KEY" + result = cache.get(key) + + if result: + return result + + result = func(*args, **kwargs) + + cache.set( + key, + result, + timeout=getattr(settings, "STATS_SETTINGS", {}).get("STATS_TIMEOUT", 3600), + ) + + return result + + return wrapper diff --git a/eox_nelp/stats/metrics.py b/eox_nelp/stats/metrics.py new file mode 100644 index 00000000..abec5361 --- /dev/null +++ b/eox_nelp/stats/metrics.py @@ -0,0 +1,136 @@ +"""eox-nelp Metrics file. + +functions: + get_cached_courses: Return visible courses. + get_course_metrics: Return the metric for the given course_key. + get_learners_metric: Return number of learners, for the visible courses. + get_instructors_metric: Return number of instructors, for the visible courses. + get_courses_metrics: Return metrics for the visible courses. +""" +from django.conf import settings + +from eox_nelp.edxapp_wrapper.branding import get_visible_courses +from eox_nelp.edxapp_wrapper.modulestore import modulestore +from eox_nelp.edxapp_wrapper.site_configuration import configuration_helpers +from eox_nelp.edxapp_wrapper.student import CourseAccessRole, CourseEnrollment +from eox_nelp.stats.decorators import cache_method + + +@cache_method +def get_cached_courses(tenant): # pylint: disable=unused-argument + """ + Returns the visible courses. This method is just a wrapper of get_visible_courses that + allows to cache the result. + + Args: + tenant: String tenant identifier(site.domain) + + Return: + list[]: List of CourseOverview records. + """ + return get_visible_courses() + + +@cache_method +def get_course_metrics(course_key): + """ + This allows to get the course stats metrics based on the course key. + + Args: + course_key: Course identifier. + + Return: + : Contains the course's metrics. + """ + stats_settings = getattr(settings, "STATS_SETTINGS", {}) + course = modulestore().get_course(course_key) + chapters = course.get_children() + + sequentials = [] + + for chapter in chapters: + sequentials += chapter.get_children() + + verticals = [] + + for sequential in sequentials: + verticals += sequential.get_children() + + allowed_block_types = stats_settings.get("API_XBLOCK_TYPES", []) + components = {key: 0 for key in allowed_block_types} + + for vertical in verticals: + for component in vertical.children: + if component.block_type in allowed_block_types: + components[component.block_type] = components.get(component.block_type, 0) + 1 + + instructors = CourseAccessRole.objects.filter(course_id=course_key).values('user').distinct().count() + learners = CourseEnrollment.objects.filter( + course=course_key, + user__is_staff=False, + user__is_superuser=False + ).values('user').distinct().count() + + return { + "id": str(course_key), + "name": course.display_name, + "learners": learners, + "instructors": instructors, + "sections": len(chapters), + "sub_sections": len(sequentials), + "units": len(verticals), + "components": components + } + + +@cache_method +def get_learners_metric(tenant): + """ + Returns the total of learners based on CourseEnrollments records. + + Args: + tenant: String tenant identifier(site.domain) + + Return: + : Total of learners. + """ + tenant_courses = get_cached_courses(tenant) + + return CourseEnrollment.objects.filter( + course__in=tenant_courses, + user__is_staff=False, + user__is_superuser=False, + ).values('user').distinct().count() + + +@cache_method +def get_instructors_metric(tenant): # pylint: disable=unused-argument + """ + Returns the total of instructors based on the accessible orgs, and the CourseAccessRole records. + + Args: + tenant: This argument is used as identifier by the cache_method decorator. + + Return: + : Total of instructors. + """ + current_site_orgs = configuration_helpers.get_current_site_orgs() + + return CourseAccessRole.objects.filter(org__in=current_site_orgs).values('user').distinct().count() + + +@cache_method +def get_courses_metrics(tenant): + """ + Returns the total of courses and its metrics. + + Args: + tenant: String tenant identifier(site.domain) + + Return: + : Contains the courses' metrics. + """ + courses = get_cached_courses(tenant) + metrics = [get_course_metrics(course.id) for course in courses] + + return {"total_courses": courses.count(), "metrics": metrics} diff --git a/eox_nelp/stats/tests/__init__.py b/eox_nelp/stats/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eox_nelp/stats/tests/api/__init__.py b/eox_nelp/stats/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eox_nelp/stats/tests/api/v1/__init__.py b/eox_nelp/stats/tests/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eox_nelp/stats/tests/api/v1/tests_views.py b/eox_nelp/stats/tests/api/v1/tests_views.py new file mode 100644 index 00000000..718fc1f5 --- /dev/null +++ b/eox_nelp/stats/tests/api/v1/tests_views.py @@ -0,0 +1,232 @@ +"""This file contains all the test for the stats api v1 views.py file. + +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 Mock, patch +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.test import APITestCase + + +@ddt +class GeneralTenantStatsViewTestCase(APITestCase): + """ Test GeneralTenantStatsView.""" + + 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_default(self, mock_metrics): + """ + Test a get request, this will verify the standard view behavior by checking the call of the metrics functions. + + Expected behavior: + - Response data contains the expected keys. + - Status code 200. + """ + 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": [ + {}, {}, {} + ], + } + 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", "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. + - get_courses_metrics is called once. + """ + total_courses = 4 + 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": total_courses, + "metrics": [fake_metric for c in range(total_courses)], + } + expected_components = {key: value * total_courses for key, value in fake_metric["components"].items()} + 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(expected_components, response.data["components"]) + mock_metrics.get_courses_metrics.assert_called_once_with("testserver") + + @override_settings(MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"]) + @data("post", "put", "patch", "delete") + def test_invalid_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:general-stats") + request = getattr(self.client, 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 length of metrics is as expected. + - get_courses_metrics is called once. + """ + total_courses = 3 + mock_metrics.get_courses_metrics.return_value = { + "total_courses": total_courses, + "metrics": [{} for c in range(total_courses)], + } + 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(total_courses, response.data["total_courses"]) + self.assertEqual(total_courses, len(response.data["metrics"])) + mock_metrics.get_courses_metrics.assert_called_once_with("testserver") + + @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) diff --git a/eox_nelp/stats/tests/tests_decorators.py b/eox_nelp/stats/tests/tests_decorators.py new file mode 100644 index 00000000..baff8f47 --- /dev/null +++ b/eox_nelp/stats/tests/tests_decorators.py @@ -0,0 +1,58 @@ +"""This file contains all the test for the stats decorator.py file. + +Classes: + TestCacheMethod: Tests cases for cache_method decorator. +""" +import unittest + +from django.core.cache import cache +from mock import Mock + +from eox_nelp.stats.decorators import cache_method + + +class TestCacheMethod(unittest.TestCase): + """Tests cases for cache_method decorator.""" + + def tearDown(self): + """Clear cache after every test to keep standard conditions""" + cache.clear() + + def test_empty_cache(self): + """Test when the cached response is not found. + + Expected behavior: + - Result contains expected value + - Test function was called with the right parameter. + - Cache was set + """ + test_function = Mock() + test_function.__name__ = "test_function" + test_function.return_value = {"test": True} + arg = "I do nothing" + + wrapper = cache_method(test_function) + result = wrapper(arg) + + self.assertTrue(result["test"]) + test_function.assert_called_once_with(arg) + self.assertTrue(cache.get("test_function.I do nothing.STATS_CACHE_KEY")) + + def test_cache_found(self): + """Test when the cached response is found. + + Expected behavior: + - Result contains expected value + - Test function was not called again. + """ + expected_value = {"cache_found": True} + cache.set("test_function.I do nothing.STATS_CACHE_KEY", expected_value) + test_function = Mock() + test_function.__name__ = "test_function" + arg = "I do nothing" + + wrapper = cache_method(test_function) + result = wrapper(arg) + + self.assertTrue(result["cache_found"]) + test_function.assert_not_called() diff --git a/eox_nelp/stats/tests/tests_metrics.py b/eox_nelp/stats/tests/tests_metrics.py new file mode 100644 index 00000000..0ad65a9f --- /dev/null +++ b/eox_nelp/stats/tests/tests_metrics.py @@ -0,0 +1,378 @@ +"""This file contains all the test for the stats metrics.py file. + +Classes: + TestGetCachedCourses: Tests cases for get_cached_courses function. + TestGetInstructorsMetric: Tests cases for get_instructors_metric function. + TestGetLearnersMetric: Tests cases for get_learners_metric function. + TestGetCoursesMetrics: Tests cases for get_courses_metrics function. + TestGetCourseMetrics: Tests cases for get_course_metrics function. +""" +import unittest + +from django.core.cache import cache +from django.test import override_settings +from mock import MagicMock, Mock, patch +from opaque_keys.edx.keys import CourseKey + +from eox_nelp.edxapp_wrapper.branding import get_visible_courses +from eox_nelp.edxapp_wrapper.modulestore import modulestore +from eox_nelp.edxapp_wrapper.site_configuration import configuration_helpers +from eox_nelp.edxapp_wrapper.student import CourseAccessRole, CourseEnrollment +from eox_nelp.stats.metrics import ( + get_cached_courses, + get_course_metrics, + get_courses_metrics, + get_instructors_metric, + get_learners_metric, +) +from eox_nelp.tests.utils import generate_list_mock_data + + +class TestGetCachedCourses(unittest.TestCase): + """Tests cases for get_cached_courses function.""" + + def tearDown(self): + """Clean cache after every test since the method uses a decorator that caches every result.""" + cache.clear() + + def test_get_visible_courses_call(self): + """Test that the method get_visible_courses is called once in the + get_cached_courses execution. + + Expected behavior: + - Result contains expected value. + - Test function was not called again. + """ + tenant = "http://test.com" + get_visible_courses.return_value = {"test": True} + + get_cached_courses(tenant) + + get_visible_courses.assert_called_once_with() + + +class TestGetInstructorsMetric(unittest.TestCase): + """Tests cases for get_instructors_metric function.""" + + def tearDown(self): + """Clean cache and restarts CourseAccessRole mock.""" + CourseAccessRole.reset_mock() + cache.clear() + + def test_get_instructors_metric(self): + """Test that the function is getting the information through the CourseAccessRole model. + + Expected behavior: + - The get_current_site_orgs method was called once. + - Result is the expected value. + - The filter method was called with the right parameters. + - The values method was called with the right parameters. + - The distinct method was called once. + - The count method was called once. + """ + tenant = "http://test.com" + filter_result = CourseAccessRole.objects.filter.return_value + values_result = filter_result.values.return_value + distinct_result = values_result.distinct.return_value + distinct_result.count.return_value = 5 + configuration_helpers.get_current_site_orgs.return_value = ["org1", "org2", "org3"] + + instructors = get_instructors_metric(tenant) + + self.assertEqual(5, instructors) + configuration_helpers.get_current_site_orgs.assert_called_once_with() + CourseAccessRole.objects.filter.assert_called_once_with(org__in=["org1", "org2", "org3"]) + filter_result.values.assert_called_once_with("user") + values_result.distinct.assert_called_once_with() + distinct_result.count.assert_called_once_with() + + +class TestGetLearnersMetric(unittest.TestCase): + """Tests cases for get_learners_metric function.""" + + def tearDown(self): + """Clean cache and restarts CourseEnrollment mock""" + CourseEnrollment.reset_mock() + cache.clear() + + @patch("eox_nelp.stats.metrics.get_cached_courses") + def test_get_learners_metric(self, get_cached_courses_mock): + """Test that the function is getting the information through the CourseEnrollment model. + + Expected behavior: + - The get_current_site_orgs method was called once. + - Result is the expected value + - The filter method was called with the right parameters. + - The values method was called with the right parameters. + - The distinct method was called once. + - The count method was called once. + """ + tenant = "http://test.com" + filter_result = CourseEnrollment.objects.filter.return_value + values_result = filter_result.values.return_value + distinct_result = values_result.distinct.return_value + distinct_result.count.return_value = 5874 + get_cached_courses_mock.return_value = ["course1", "course2", "course3"] + + learners = get_learners_metric(tenant) + + self.assertEqual(5874, learners) + configuration_helpers.get_current_site_orgs.assert_called_once_with() + CourseEnrollment.objects.filter.assert_called_once_with( + course__in=["course1", "course2", "course3"], + user__is_staff=False, + user__is_superuser=False, + ) + filter_result.values.assert_called_once_with("user") + values_result.distinct.assert_called_once_with() + distinct_result.count.assert_called_once_with() + get_cached_courses_mock.assert_called_once_with(tenant) + + +class TestGetCoursesMetrics(unittest.TestCase): + """Tests cases for get_courses_metrics function.""" + + def tearDown(self): + """Clean cache after every test since the method uses a decorator that caches every result.""" + cache.clear() + + @patch("eox_nelp.stats.metrics.get_cached_courses") + @patch("eox_nelp.stats.metrics.get_course_metrics") + def test_get_courses_metrics(self, get_course_metrics_mock, get_cached_courses_mock): + """The method get_courses_metrics just calls get_course_metrics multiple times, based on + the available courses, So this test just verifies that the method get_course_metrics is called + for every result of get_cached_courses. + + Expected behavior: + - total_course returns the expected value. + - metrics has the same length as the returned courses, + - get_cached_courses was called once. + - get_course_metrics_mock was called multiple times. + - the time that get_course_metrics_mock was called is the same number of courses. + """ + tenant = "http://test.com" + courses = MagicMock() + courses.__iter__.return_value = iter([ + Mock(), + Mock(), + Mock(), + Mock(), + ]) + courses.count.return_value = 4 + get_cached_courses_mock.return_value = courses + get_course_metrics_mock.return_value = { + "name": "test-course" + } + + metrics = get_courses_metrics(tenant) + + self.assertEqual(4, metrics["total_courses"]) + self.assertEqual(4, len(metrics["metrics"])) + get_cached_courses_mock.assert_called_once_with(tenant) + get_course_metrics_mock.assert_called() + self.assertEqual(4, get_course_metrics_mock.call_count) + + +class TestGetCourseMetrics(unittest.TestCase): + """Tests cases for get_courses_metrics function.""" + + def setUp(self): # pylint: disable=invalid-name + """ + Set base variables and objects across metrics test cases. + """ + self.course_key = CourseKey.from_string("course-v1:test+Cx105+2022_T4") + # Prepare verticals + self.verticals = generate_list_mock_data([ + { + "children": [ + { + "block_type": "problem", + }, + { + "block_type": "html", + }, + { + "block_type": "html", + }, + ] + }, + { + "children": [ + { + "block_type": "problem", + }, + { + "block_type": "video", + }, + { + "block_type": "html", + }, + ] + }, + { + "children": [ + { + "block_type": "problem", + }, + { + "block_type": "video", + }, + { + "block_type": "html", + }, + ] + }, + ]) + # Prepare sequentials + sequential = Mock() + sequential.get_children.return_value = self.verticals + self.sequentials = [ + sequential, + sequential, + sequential, + ] + # Prepare chapters + chapter = Mock() + chapter.get_children.return_value = self.sequentials + self.chapters = [ + chapter, + chapter, + chapter, + chapter, + ] + # Prepare course + course = Mock() + course.display_name = "testing" + course.get_children.return_value = self.chapters + # Set course + modulestore.return_value.get_course.return_value = course + self.expected_returned_enrollments = 5874 + self.expected_returned_roles = 5 + + # this block set the CourseEnrollment mock and its returned values. + filter_result = CourseEnrollment.objects.filter.return_value + values_result = filter_result.values.return_value + distinct_result = values_result.distinct.return_value + distinct_result.count.return_value = self.expected_returned_enrollments + + # this block set the CourseAccessRole mock and its returned values. + filter_result = CourseAccessRole.objects.filter.return_value + values_result = filter_result.values.return_value + distinct_result = values_result.distinct.return_value + distinct_result.count.return_value = self.expected_returned_roles + + def tearDown(self): + """Clean cache and restarts mocks""" + # This line just verifies that de get_course modulestore method cwas called with the right parameter + modulestore.return_value.get_course.assert_called_once_with(self.course_key) + + CourseAccessRole.reset_mock() + CourseEnrollment.reset_mock() + modulestore.reset_mock() + cache.clear() + + def test_get_right_id(self): + """Based on the initial conditions, this check that the course metrics has the expected id. + + Expected behavior: + - 'id' value is the expected. + """ + course = get_course_metrics(self.course_key) + + self.assertEqual(str(self.course_key), course["id"]) + + def test_get_right_name(self): + """Setting a different course display name, this verifies that the course metrics has the same name. + + Expected behavior: + - 'name' value is the expected + """ + modulestore.return_value.get_course.return_value.display_name = "Amazing course" + + course = get_course_metrics(self.course_key) + + self.assertEqual("Amazing course", course["name"]) + + def test_get_right_learners_metric(self): + """Based on the initial conditions, this check that the course metrics has the expected learners value. + + Expected behavior: + - 'learners' value is the expected + """ + course = get_course_metrics(self.course_key) + + self.assertEqual(self.expected_returned_enrollments, course["learners"]) + + def test_get_right_instructors_metric(self): + """Based on the initial conditions, this check that the course metrics has the expected instructors value. + + Expected behavior: + - 'instructors' value is the expected + """ + course = get_course_metrics(self.course_key) + + self.assertEqual(self.expected_returned_roles, course["instructors"]) + + def test_get_right_sections_metric(self): + """Based on the initial conditions, this check that the course metrics has the expected sections value. + + Expected behavior: + - 'sections' value is the expected + """ + course = get_course_metrics(self.course_key) + + self.assertEqual(len(self.chapters), course["sections"]) + + def test_get_right_sub_sections_metric(self): + """Based on the initial conditions, this check that the course metrics has the expected sub_sections value. + + Expected behavior: + - 'sub_sections' value is the expected + """ + course = get_course_metrics(self.course_key) + + self.assertEqual(len(self.chapters) * len(self.sequentials), course["sub_sections"]) + + def test_get_right_units_metric(self): + """Based on the initial conditions, this check that the course metrics has the expected units value. + + Expected behavior: + - 'units' value is the expected + """ + course = get_course_metrics(self.course_key) + + self.assertEqual(len(self.chapters) * len(self.sequentials) * len(self.verticals), course["units"]) + + def test_set_empty_allowed_components(self): + """Based on the initial conditions, this check that the course metrics has the expected components value. + + Expected behavior: + - Components is an empty list + """ + course = get_course_metrics(self.course_key) + + self.assertFalse(course["components"]) + + @override_settings(STATS_SETTINGS={"API_XBLOCK_TYPES": ["html", "problem", "video"]}) + def test_set_allowed_components(self): + """This modifies the STATS_SETTINGS value in order to test that + the components returns the expect type of components. + + Expected behavior: + - 'html' value is the expected + - 'problem' value is the expected + - 'video' value is the expected + """ + expected_components = {} + + for vertical in self.verticals: + for child in vertical.children: + expected_components[child.block_type] = expected_components.get(child.block_type, 0) + 1 + + expected_components = { + k: (v * len(self.chapters) * len(self.sequentials)) for k, v in expected_components.items() + } + + course = get_course_metrics(self.course_key) + + self.assertEqual(expected_components, course["components"]) diff --git a/eox_nelp/tests/utils.py b/eox_nelp/tests/utils.py index 24d761d4..da7b8410 100644 --- a/eox_nelp/tests/utils.py +++ b/eox_nelp/tests/utils.py @@ -20,6 +20,20 @@ def generate_list_mock_data(data): "due": "due_date", "location": "location" }, + { + "due" : "due_date", + "components": [ + { + "block_type": "problem", + }, + { + "block_type": "video", + }, + { + "block_type": "html", + }, + ] + }, ] Every dictionary should be direct key values.No way if there is nested dict the model @@ -43,6 +57,8 @@ def set_key_values(element): for key, value in element.items(): if isinstance(value, dict): setattr(model, key, set_key_values(value)) + elif isinstance(value, list): + setattr(model, key, generate_list_mock_data(value)) else: setattr(model, key, value) return model diff --git a/eox_nelp/urls.py b/eox_nelp/urls.py index d33b3a88..1f9ed879 100644 --- a/eox_nelp/urls.py +++ b/eox_nelp/urls.py @@ -28,4 +28,5 @@ 'frontend/experience/', include('eox_nelp.course_experience.frontend.urls', namespace='course-experience-frontend'), ), + path('api/stats/', include('eox_nelp.stats.api.urls', namespace='stats-api')), ]