Skip to content

Commit

Permalink
feat: add course list and specific views
Browse files Browse the repository at this point in the history
  • Loading branch information
andrey-canon committed Jun 28, 2023
1 parent a16a966 commit d4eeb91
Show file tree
Hide file tree
Showing 3 changed files with 283 additions and 36 deletions.
9 changes: 6 additions & 3 deletions eox_nelp/stats/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -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"),
]
129 changes: 103 additions & 26 deletions eox_nelp/stats/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
}
```
Expand All @@ -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))
181 changes: 174 additions & 7 deletions eox_nelp/stats/tests/api/v1/tests_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -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)

0 comments on commit d4eeb91

Please sign in to comment.