edx-enterprise-data 8.7.0__py3-none-any.whl → 8.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {edx_enterprise_data-8.7.0.dist-info → edx_enterprise_data-8.8.1.dist-info}/METADATA +1 -1
- {edx_enterprise_data-8.7.0.dist-info → edx_enterprise_data-8.8.1.dist-info}/RECORD +23 -20
- enterprise_data/__init__.py +1 -1
- enterprise_data/admin_analytics/constants.py +9 -3
- enterprise_data/admin_analytics/data_loaders.py +7 -3
- enterprise_data/admin_analytics/utils.py +52 -10
- enterprise_data/api/v1/paginators.py +1 -1
- enterprise_data/api/v1/serializers.py +39 -30
- enterprise_data/api/v1/urls.py +12 -6
- enterprise_data/api/v1/views/analytics_enrollments.py +85 -73
- enterprise_data/api/v1/views/analytics_leaderboard.py +141 -0
- enterprise_data/api/v1/views/enterprise_admin.py +20 -19
- enterprise_data/api/v1/views/enterprise_completions.py +49 -28
- enterprise_data/renderers.py +14 -0
- enterprise_data/tests/admin_analytics/mock_analytics_data.py +511 -0
- enterprise_data/tests/admin_analytics/mock_enrollments.py +4 -4
- enterprise_data/tests/admin_analytics/test_analytics_enrollments.py +23 -22
- enterprise_data/tests/admin_analytics/test_analytics_leaderboard.py +163 -0
- enterprise_data/tests/admin_analytics/test_enterprise_completions.py +1 -1
- enterprise_data/utils.py +16 -0
- {edx_enterprise_data-8.7.0.dist-info → edx_enterprise_data-8.8.1.dist-info}/LICENSE +0 -0
- {edx_enterprise_data-8.7.0.dist-info → edx_enterprise_data-8.8.1.dist-info}/WHEEL +0 -0
- {edx_enterprise_data-8.7.0.dist-info → edx_enterprise_data-8.8.1.dist-info}/top_level.txt +0 -0
@@ -8,10 +8,10 @@ from rest_framework import status
|
|
8
8
|
from rest_framework.reverse import reverse
|
9
9
|
from rest_framework.test import APITransactionTestCase
|
10
10
|
|
11
|
-
from enterprise_data.admin_analytics.constants import
|
12
|
-
from enterprise_data.api.v1.serializers import AdvanceAnalyticsEnrollmentSerializer as EnrollmentSerializer
|
11
|
+
from enterprise_data.admin_analytics.constants import EnrollmentChart, ResponseType
|
13
12
|
from enterprise_data.api.v1.serializers import AdvanceAnalyticsEnrollmentStatsSerializer as EnrollmentStatsSerializer
|
14
|
-
from enterprise_data.
|
13
|
+
from enterprise_data.api.v1.serializers import AdvanceAnalyticsQueryParamSerializer
|
14
|
+
from enterprise_data.tests.admin_analytics.mock_analytics_data import (
|
15
15
|
ENROLLMENT_STATS_CSVS,
|
16
16
|
ENROLLMENTS,
|
17
17
|
enrollments_csv_content,
|
@@ -23,13 +23,13 @@ from enterprise_data_roles.constants import ENTERPRISE_DATA_ADMIN_ROLE
|
|
23
23
|
from enterprise_data_roles.models import EnterpriseDataFeatureRole, EnterpriseDataRoleAssignment
|
24
24
|
|
25
25
|
INVALID_CALCULATION_ERROR = (
|
26
|
-
f"Calculation must be one of {
|
26
|
+
f"Calculation must be one of {AdvanceAnalyticsQueryParamSerializer.CALCULATION_CHOICES}"
|
27
27
|
)
|
28
28
|
INVALID_GRANULARITY_ERROR = (
|
29
|
-
f"Granularity must be one of {
|
29
|
+
f"Granularity must be one of {AdvanceAnalyticsQueryParamSerializer.GRANULARITY_CHOICES}"
|
30
30
|
)
|
31
|
-
|
32
|
-
|
31
|
+
INVALID_RESPONSE_TYPE_ERROR = f"response_type must be one of {AdvanceAnalyticsQueryParamSerializer.RESPONSE_TYPES}"
|
32
|
+
INVALID_CHART_TYPE_ERROR = f"chart_type must be one of {EnrollmentStatsSerializer.CHART_TYPES}"
|
33
33
|
|
34
34
|
|
35
35
|
@ddt.ddt
|
@@ -59,7 +59,7 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
59
59
|
)
|
60
60
|
|
61
61
|
fetch_max_enrollment_datetime_patcher = patch(
|
62
|
-
'enterprise_data.api.v1.views.analytics_enrollments.
|
62
|
+
'enterprise_data.api.v1.views.analytics_enrollments.fetch_enrollments_cache_expiry_timestamp',
|
63
63
|
return_value=datetime.now()
|
64
64
|
)
|
65
65
|
|
@@ -100,6 +100,7 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
100
100
|
|
101
101
|
response = self.client.get(self.url, {"page_size": 2})
|
102
102
|
assert response.status_code == status.HTTP_200_OK
|
103
|
+
assert response["Content-Type"] == "application/json"
|
103
104
|
data = response.json()
|
104
105
|
assert data["next"] == f"http://testserver{self.url}?page=2&page_size=2"
|
105
106
|
assert data["previous"] is None
|
@@ -110,6 +111,7 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
110
111
|
|
111
112
|
response = self.client.get(self.url, {"page_size": 2, "page": 2})
|
112
113
|
assert response.status_code == status.HTTP_200_OK
|
114
|
+
assert response["Content-Type"] == "application/json"
|
113
115
|
data = response.json()
|
114
116
|
assert data["next"] == f"http://testserver{self.url}?page=3&page_size=2"
|
115
117
|
assert data["previous"] == f"http://testserver{self.url}?page_size=2"
|
@@ -120,6 +122,7 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
120
122
|
|
121
123
|
response = self.client.get(self.url, {"page_size": 2, "page": 3})
|
122
124
|
assert response.status_code == status.HTTP_200_OK
|
125
|
+
assert response["Content-Type"] == "application/json"
|
123
126
|
data = response.json()
|
124
127
|
assert data["next"] is None
|
125
128
|
assert data["previous"] == f"http://testserver{self.url}?page=2&page_size=2"
|
@@ -130,6 +133,7 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
130
133
|
|
131
134
|
response = self.client.get(self.url, {"page_size": 5})
|
132
135
|
assert response.status_code == status.HTTP_200_OK
|
136
|
+
assert response["Content-Type"] == "application/json"
|
133
137
|
data = response.json()
|
134
138
|
assert data["next"] is None
|
135
139
|
assert data["previous"] is None
|
@@ -146,15 +150,11 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
146
150
|
Test the GET method for the AdvanceAnalyticsIndividualEnrollmentsView return correct CSV data.
|
147
151
|
"""
|
148
152
|
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
149
|
-
response = self.client.get(self.url, {"
|
153
|
+
response = self.client.get(self.url, {"response_type": ResponseType.CSV.value})
|
150
154
|
assert response.status_code == status.HTTP_200_OK
|
151
155
|
|
152
156
|
# verify the response headers
|
153
157
|
assert response["Content-Type"] == "text/csv"
|
154
|
-
assert (
|
155
|
-
response["Content-Disposition"]
|
156
|
-
== 'attachment; filename="individual_enrollments.csv"'
|
157
|
-
)
|
158
158
|
|
159
159
|
# verify the response content
|
160
160
|
content = b"".join(response.streaming_content)
|
@@ -193,7 +193,7 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
193
193
|
"params": {"granularity": "invalid"},
|
194
194
|
"error": {"granularity": [INVALID_GRANULARITY_ERROR]},
|
195
195
|
},
|
196
|
-
{"params": {"
|
196
|
+
{"params": {"response_type": "invalid"}, "error": {"response_type": [INVALID_RESPONSE_TYPE_ERROR]}},
|
197
197
|
)
|
198
198
|
@ddt.unpack
|
199
199
|
def test_get_invalid_query_params(self, params, error):
|
@@ -232,7 +232,7 @@ class TestEnrollmentStatsAPI(JWTTestMixin, APITransactionTestCase):
|
|
232
232
|
)
|
233
233
|
|
234
234
|
fetch_max_enrollment_datetime_patcher = patch(
|
235
|
-
'enterprise_data.api.v1.views.analytics_enrollments.
|
235
|
+
'enterprise_data.api.v1.views.analytics_enrollments.fetch_enrollments_cache_expiry_timestamp',
|
236
236
|
return_value=datetime.now()
|
237
237
|
)
|
238
238
|
|
@@ -271,6 +271,7 @@ class TestEnrollmentStatsAPI(JWTTestMixin, APITransactionTestCase):
|
|
271
271
|
|
272
272
|
response = self.client.get(self.url)
|
273
273
|
assert response.status_code == status.HTTP_200_OK
|
274
|
+
assert response["Content-Type"] == "application/json"
|
274
275
|
data = response.json()
|
275
276
|
assert data == {
|
276
277
|
"enrollments_over_time": [
|
@@ -332,21 +333,21 @@ class TestEnrollmentStatsAPI(JWTTestMixin, APITransactionTestCase):
|
|
332
333
|
|
333
334
|
@patch("enterprise_data.api.v1.views.analytics_enrollments.fetch_and_cache_enrollments_data")
|
334
335
|
@ddt.data(
|
335
|
-
|
336
|
-
|
337
|
-
|
336
|
+
EnrollmentChart.ENROLLMENTS_OVER_TIME.value,
|
337
|
+
EnrollmentChart.TOP_COURSES_BY_ENROLLMENTS.value,
|
338
|
+
EnrollmentChart.TOP_SUBJECTS_BY_ENROLLMENTS.value,
|
338
339
|
)
|
339
|
-
def test_get_csv(self,
|
340
|
+
def test_get_csv(self, chart_type, mock_fetch_and_cache_enrollments_data):
|
340
341
|
"""
|
341
342
|
Test that AdvanceAnalyticsEnrollmentStatsView return correct CSV data.
|
342
343
|
"""
|
343
344
|
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
344
345
|
|
345
|
-
response = self.client.get(self.url, {"
|
346
|
+
response = self.client.get(self.url, {"response_type": ResponseType.CSV.value, "chart_type": chart_type})
|
346
347
|
assert response.status_code == status.HTTP_200_OK
|
347
348
|
assert response["Content-Type"] == "text/csv"
|
348
349
|
# verify the response content
|
349
|
-
assert response.content == ENROLLMENT_STATS_CSVS[
|
350
|
+
assert response.content == ENROLLMENT_STATS_CSVS[chart_type]
|
350
351
|
|
351
352
|
@ddt.data(
|
352
353
|
{
|
@@ -381,7 +382,7 @@ class TestEnrollmentStatsAPI(JWTTestMixin, APITransactionTestCase):
|
|
381
382
|
"params": {"granularity": "invalid"},
|
382
383
|
"error": {"granularity": [INVALID_GRANULARITY_ERROR]},
|
383
384
|
},
|
384
|
-
{"params": {"
|
385
|
+
{"params": {"chart_type": "invalid"}, "error": {"chart_type": [INVALID_CHART_TYPE_ERROR]}},
|
385
386
|
)
|
386
387
|
@ddt.unpack
|
387
388
|
def test_get_invalid_query_params(self, params, error):
|
@@ -0,0 +1,163 @@
|
|
1
|
+
"""Unittests for analytics_enrollments.py"""
|
2
|
+
|
3
|
+
from datetime import datetime
|
4
|
+
|
5
|
+
import ddt
|
6
|
+
from mock import patch
|
7
|
+
from rest_framework import status
|
8
|
+
from rest_framework.reverse import reverse
|
9
|
+
from rest_framework.test import APITransactionTestCase
|
10
|
+
|
11
|
+
from enterprise_data.admin_analytics.constants import ResponseType
|
12
|
+
from enterprise_data.tests.admin_analytics.mock_analytics_data import (
|
13
|
+
ENROLLMENTS,
|
14
|
+
LEADERBOARD_RESPONSE,
|
15
|
+
engagements_dataframe,
|
16
|
+
enrollments_dataframe,
|
17
|
+
leaderboard_csv_content,
|
18
|
+
)
|
19
|
+
from enterprise_data.tests.mixins import JWTTestMixin
|
20
|
+
from enterprise_data.tests.test_utils import UserFactory
|
21
|
+
from enterprise_data_roles.constants import ENTERPRISE_DATA_ADMIN_ROLE
|
22
|
+
from enterprise_data_roles.models import EnterpriseDataFeatureRole, EnterpriseDataRoleAssignment
|
23
|
+
|
24
|
+
|
25
|
+
@ddt.ddt
|
26
|
+
class TestLeaderboardAPI(JWTTestMixin, APITransactionTestCase):
|
27
|
+
"""Tests for AdvanceAnalyticsLeaderboardView."""
|
28
|
+
|
29
|
+
def setUp(self):
|
30
|
+
"""
|
31
|
+
Setup method.
|
32
|
+
"""
|
33
|
+
super().setUp()
|
34
|
+
self.user = UserFactory(is_staff=True)
|
35
|
+
role, __ = EnterpriseDataFeatureRole.objects.get_or_create(
|
36
|
+
name=ENTERPRISE_DATA_ADMIN_ROLE
|
37
|
+
)
|
38
|
+
self.role_assignment = EnterpriseDataRoleAssignment.objects.create(
|
39
|
+
role=role, user=self.user
|
40
|
+
)
|
41
|
+
self.client.force_authenticate(user=self.user)
|
42
|
+
|
43
|
+
self.enterprise_uuid = "ee5e6b3a-069a-4947-bb8d-d2dbc323396c"
|
44
|
+
self.set_jwt_cookie()
|
45
|
+
|
46
|
+
self.url = reverse(
|
47
|
+
"v1:enterprise-admin-analytics-leaderboard",
|
48
|
+
kwargs={"enterprise_uuid": self.enterprise_uuid},
|
49
|
+
)
|
50
|
+
|
51
|
+
fetch_max_enrollment_datetime_patcher = patch(
|
52
|
+
'enterprise_data.api.v1.views.analytics_leaderboard.fetch_enrollments_cache_expiry_timestamp',
|
53
|
+
return_value=datetime.now()
|
54
|
+
)
|
55
|
+
|
56
|
+
fetch_max_enrollment_datetime_patcher.start()
|
57
|
+
self.addCleanup(fetch_max_enrollment_datetime_patcher.stop)
|
58
|
+
|
59
|
+
fetch_max_engagement_datetime_patcher = patch(
|
60
|
+
'enterprise_data.api.v1.views.analytics_leaderboard.fetch_engagements_cache_expiry_timestamp',
|
61
|
+
return_value=datetime.now()
|
62
|
+
)
|
63
|
+
|
64
|
+
fetch_max_engagement_datetime_patcher.start()
|
65
|
+
self.addCleanup(fetch_max_engagement_datetime_patcher.stop)
|
66
|
+
|
67
|
+
def verify_enrollment_data(self, results, results_count):
|
68
|
+
"""Verify the received enrollment data."""
|
69
|
+
attrs = [
|
70
|
+
"email",
|
71
|
+
"course_title",
|
72
|
+
"course_subject",
|
73
|
+
"enroll_type",
|
74
|
+
"enterprise_enrollment_date",
|
75
|
+
]
|
76
|
+
|
77
|
+
assert len(results) == results_count
|
78
|
+
|
79
|
+
filtered_data = []
|
80
|
+
for enrollment in ENROLLMENTS:
|
81
|
+
for result in results:
|
82
|
+
if enrollment["email"] == result["email"]:
|
83
|
+
filtered_data.append({attr: enrollment[attr] for attr in attrs})
|
84
|
+
break
|
85
|
+
|
86
|
+
received_data = sorted(results, key=lambda x: x["email"])
|
87
|
+
expected_data = sorted(filtered_data, key=lambda x: x["email"])
|
88
|
+
assert received_data == expected_data
|
89
|
+
|
90
|
+
@patch(
|
91
|
+
"enterprise_data.api.v1.views.analytics_leaderboard.fetch_and_cache_enrollments_data"
|
92
|
+
)
|
93
|
+
@patch(
|
94
|
+
"enterprise_data.api.v1.views.analytics_leaderboard.fetch_and_cache_engagements_data"
|
95
|
+
)
|
96
|
+
def test_get(self, mock_fetch_and_cache_engagements_data, mock_fetch_and_cache_enrollments_data):
|
97
|
+
"""
|
98
|
+
Test the GET method for the AdvanceAnalyticsLeaderboardView works.
|
99
|
+
"""
|
100
|
+
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
101
|
+
mock_fetch_and_cache_engagements_data.return_value = engagements_dataframe()
|
102
|
+
|
103
|
+
response = self.client.get(self.url, {"page_size": 2})
|
104
|
+
assert response.status_code == status.HTTP_200_OK
|
105
|
+
assert response["Content-Type"] == "application/json"
|
106
|
+
data = response.json()
|
107
|
+
assert data["next"] == f'http://testserver{self.url}?page=2&page_size=2'
|
108
|
+
assert data["previous"] is None
|
109
|
+
assert data["current_page"] == 1
|
110
|
+
assert data["num_pages"] == 6
|
111
|
+
assert data["count"] == 12
|
112
|
+
assert data["results"] == [
|
113
|
+
{
|
114
|
+
"email": "paul77@example.org",
|
115
|
+
"daily_sessions": 1,
|
116
|
+
"learning_time_seconds": 15753,
|
117
|
+
"learning_time_hours": 4.4,
|
118
|
+
"average_session_length": 4.4,
|
119
|
+
"course_completions": None,
|
120
|
+
},
|
121
|
+
{
|
122
|
+
"email": "seth57@example.org",
|
123
|
+
"daily_sessions": 1,
|
124
|
+
"learning_time_seconds": 9898,
|
125
|
+
"learning_time_hours": 2.7,
|
126
|
+
"average_session_length": 2.7,
|
127
|
+
"course_completions": None,
|
128
|
+
},
|
129
|
+
]
|
130
|
+
|
131
|
+
# fetch all records
|
132
|
+
response = self.client.get(self.url, {"page_size": 20})
|
133
|
+
assert response.status_code == status.HTTP_200_OK
|
134
|
+
data = response.json()
|
135
|
+
assert data["next"] is None
|
136
|
+
assert data["previous"] is None
|
137
|
+
assert data["current_page"] == 1
|
138
|
+
assert data["num_pages"] == 1
|
139
|
+
assert data["count"] == 12
|
140
|
+
assert data["results"] == LEADERBOARD_RESPONSE
|
141
|
+
|
142
|
+
@patch(
|
143
|
+
"enterprise_data.api.v1.views.analytics_leaderboard.fetch_and_cache_enrollments_data"
|
144
|
+
)
|
145
|
+
@patch(
|
146
|
+
"enterprise_data.api.v1.views.analytics_leaderboard.fetch_and_cache_engagements_data"
|
147
|
+
)
|
148
|
+
def test_get_csv(self, mock_fetch_and_cache_engagements_data, mock_fetch_and_cache_enrollments_data):
|
149
|
+
"""
|
150
|
+
Test the GET method for the AdvanceAnalyticsIndividualEnrollmentsView return correct CSV data.
|
151
|
+
"""
|
152
|
+
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
153
|
+
mock_fetch_and_cache_engagements_data.return_value = engagements_dataframe()
|
154
|
+
|
155
|
+
response = self.client.get(self.url, {"response_type": ResponseType.CSV.value})
|
156
|
+
assert response.status_code == status.HTTP_200_OK
|
157
|
+
|
158
|
+
# verify the response headers
|
159
|
+
assert response["Content-Type"] == "text/csv"
|
160
|
+
|
161
|
+
# verify the response content
|
162
|
+
content = b"".join(response.streaming_content)
|
163
|
+
assert content == leaderboard_csv_content()
|
@@ -8,7 +8,7 @@ from rest_framework.reverse import reverse
|
|
8
8
|
from rest_framework.test import APITransactionTestCase
|
9
9
|
|
10
10
|
from enterprise_data.admin_analytics.utils import ChartType
|
11
|
-
from enterprise_data.tests.admin_analytics.
|
11
|
+
from enterprise_data.tests.admin_analytics.mock_analytics_data import (
|
12
12
|
COMPLETIONS_STATS_CSVS,
|
13
13
|
ENROLLMENTS,
|
14
14
|
enrollments_dataframe,
|
enterprise_data/utils.py
CHANGED
@@ -4,6 +4,7 @@ Utility functions for Enterprise Data app.
|
|
4
4
|
import hashlib
|
5
5
|
import random
|
6
6
|
import time
|
7
|
+
from contextlib import contextmanager
|
7
8
|
from datetime import timedelta
|
8
9
|
from functools import wraps
|
9
10
|
from logging import getLogger
|
@@ -68,6 +69,21 @@ def timeit(func):
|
|
68
69
|
return wrapper
|
69
70
|
|
70
71
|
|
72
|
+
@contextmanager
|
73
|
+
def timer(prefix):
|
74
|
+
"""
|
75
|
+
Context manager to measure the time taken by a block of code.
|
76
|
+
|
77
|
+
Arguments:
|
78
|
+
prefix (str): The prefix to print in the log.
|
79
|
+
"""
|
80
|
+
start = time.time()
|
81
|
+
yield
|
82
|
+
end = time.time()
|
83
|
+
difference = end - start
|
84
|
+
print(f"TIMER:: {prefix} took {difference:.20f} seconds")
|
85
|
+
|
86
|
+
|
71
87
|
def date_filter(start, end, data_frame, date_column):
|
72
88
|
"""
|
73
89
|
Filter a pandas DataFrame by date range.
|
File without changes
|
File without changes
|
File without changes
|