edx-enterprise-data 8.9.0__py3-none-any.whl → 8.10.0__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.9.0.dist-info → edx_enterprise_data-8.10.0.dist-info}/METADATA +1 -1
- {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.10.0.dist-info}/RECORD +17 -15
- enterprise_data/__init__.py +1 -1
- enterprise_data/admin_analytics/constants.py +8 -0
- enterprise_data/api/v1/serializers.py +31 -1
- enterprise_data/api/v1/urls.py +14 -0
- enterprise_data/api/v1/views/analytics_engagements.py +395 -0
- enterprise_data/api/v1/views/analytics_enrollments.py +1 -0
- enterprise_data/api/v1/views/analytics_leaderboard.py +4 -1
- enterprise_data/api/v1/views/enterprise_admin.py +1 -0
- enterprise_data/api/v1/views/enterprise_completions.py +2 -0
- enterprise_data/renderers.py +14 -0
- enterprise_data/tests/admin_analytics/mock_analytics_data.py +41 -1
- enterprise_data/tests/admin_analytics/test_analytics_engagements.py +390 -0
- {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.10.0.dist-info}/LICENSE +0 -0
- {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.10.0.dist-info}/WHEEL +0 -0
- {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.10.0.dist-info}/top_level.txt +0 -0
@@ -1,17 +1,17 @@
|
|
1
|
-
enterprise_data/__init__.py,sha256=
|
1
|
+
enterprise_data/__init__.py,sha256=Cji_MQl_02kLp1gNtiDwEL03f3XaG45wQPHOFScJs1I,124
|
2
2
|
enterprise_data/apps.py,sha256=aF6hZwDfI2oWj95tUTm_2ikHueQj-jLj-u0GrgzpsQI,414
|
3
3
|
enterprise_data/clients.py,sha256=GvQupy5TVYfO_IKC3yzXSAgNP54r-PtIjidM5ws9Iks,3947
|
4
4
|
enterprise_data/constants.py,sha256=uCKjfpdlMYFZJsAj3n9RMw4Cmg5_6s3NuwocO-fch3s,238
|
5
5
|
enterprise_data/filters.py,sha256=D2EiK12MMpBoz6eOUmTpoJEhj_sH7bA93NRRAdvkDVo,6163
|
6
6
|
enterprise_data/models.py,sha256=khGcOh7NWP8KGu84t78Y2zAu3knREeXA_prApmU2NX8,24428
|
7
7
|
enterprise_data/paginators.py,sha256=YPrC5TeXFt-ymenT2H8H2nCbDCnAzJQlH9kFPElRxWE,269
|
8
|
-
enterprise_data/renderers.py,sha256=
|
8
|
+
enterprise_data/renderers.py,sha256=VaCymaZUhYEZ6yvryoASEohKeeL6lcPSWhFCnyfzjps,2699
|
9
9
|
enterprise_data/signals.py,sha256=8eqNPnlvmfsKf19lGWv5xTIuBgQIqR8EZSp9UYzC8Rc,1024
|
10
10
|
enterprise_data/urls.py,sha256=bqtKF5OEWEwrNmHG3os-pZNuNsmjlhxEqp7yM4TbPf4,243
|
11
11
|
enterprise_data/utils.py,sha256=_zK3BErjZSIkP_JNzK8m-DR5pRTnxKylP9I-vURaRcE,3009
|
12
12
|
enterprise_data/admin_analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
13
|
enterprise_data/admin_analytics/completions_utils.py,sha256=kGmLy7x6aD0coNYgzLa5XzJypLkGTT5clDHLSH_QFDE,9442
|
14
|
-
enterprise_data/admin_analytics/constants.py,sha256=
|
14
|
+
enterprise_data/admin_analytics/constants.py,sha256=7WturLuMISekgcHHlgj45PPdPrDTYM-l21lA8-Q_Tfc,1107
|
15
15
|
enterprise_data/admin_analytics/data_loaders.py,sha256=4PSsIveNBtpFqwrLUjTl5oywHVYFAMdZrNRHRGMk4XY,6116
|
16
16
|
enterprise_data/admin_analytics/database.py,sha256=mNS_9xE5h6O7oMMzr6kr6LDTTSNvKzo8vaM-YG8tOd8,1312
|
17
17
|
enterprise_data/admin_analytics/utils.py,sha256=CQuTlg36AALJiopp4us-JN8oTXsw-jDXSJenbphLDME,12270
|
@@ -23,14 +23,15 @@ enterprise_data/api/v0/urls.py,sha256=vzJjqIo_S3AXWs9Us8XTaJc3FnxLbYzAkmLyuDQqum
|
|
23
23
|
enterprise_data/api/v0/views.py,sha256=4RslZ4NZOU-844bnebEQ71ji2utRY7jEijqC45oQQD0,14380
|
24
24
|
enterprise_data/api/v1/__init__.py,sha256=1aAzAYU5hk-RW6cKUxa1645cbZMxn7GIZ7OMjWc9MKI,46
|
25
25
|
enterprise_data/api/v1/paginators.py,sha256=f0xsilLaU94jSBltJk46tR1rLEIt7YrqSzMAAVtPXjA,3592
|
26
|
-
enterprise_data/api/v1/serializers.py,sha256=
|
27
|
-
enterprise_data/api/v1/urls.py,sha256=
|
26
|
+
enterprise_data/api/v1/serializers.py,sha256=Kk4zuRNcr4bfMYIwi2iXQAr_NM3OXuXNfxfrz7xO2iE,13194
|
27
|
+
enterprise_data/api/v1/urls.py,sha256=nFcbPSfAIKW6Eiwd1vN0WMqdtBfh9_bBqAFCVrzRm5I,4091
|
28
28
|
enterprise_data/api/v1/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
29
|
-
enterprise_data/api/v1/views/
|
30
|
-
enterprise_data/api/v1/views/
|
29
|
+
enterprise_data/api/v1/views/analytics_engagements.py,sha256=8H3Fk-hTqJaU3H5Lpu1kFWq7p7xDp1pjge-sK7osDbg,17553
|
30
|
+
enterprise_data/api/v1/views/analytics_enrollments.py,sha256=trC92GOej6vUoju53KT3dPEDnoPHdukfXEIXPThz76A,16886
|
31
|
+
enterprise_data/api/v1/views/analytics_leaderboard.py,sha256=2DALqzUIbe4-ZGgHHIkYAKJ5L1ik2ruPtQNYtTdPba4,5974
|
31
32
|
enterprise_data/api/v1/views/base.py,sha256=FTAxlz5EzvAY657wzVgzhJPFSCHHzct7IDcvm71Smt8,866
|
32
|
-
enterprise_data/api/v1/views/enterprise_admin.py,sha256=
|
33
|
-
enterprise_data/api/v1/views/enterprise_completions.py,sha256=
|
33
|
+
enterprise_data/api/v1/views/enterprise_admin.py,sha256=HiFJLFWCHn4UbRIlN9TlqIwTJWrse5b4qf3gQmx4QLY,9352
|
34
|
+
enterprise_data/api/v1/views/enterprise_completions.py,sha256=bJG2ZtTbLyiBrj64iJHQNHEKLrJCzl9OuJ7nDtw-9aY,8377
|
34
35
|
enterprise_data/api/v1/views/enterprise_learner.py,sha256=yABjJje3CT8I8YOhWr1_tTkdKtnGJom8eu3EFz_-0BU,18517
|
35
36
|
enterprise_data/api/v1/views/enterprise_offers.py,sha256=VifxgqTLFLVw4extYPlHcN1N_yjXcsYsAlYEnAbpb10,1266
|
36
37
|
enterprise_data/fixtures/enterprise_enrollment.json,sha256=6onPXXR29pMdTdbl_mn81sDi3Re5jkLUZz2TPMB_1IY,5786
|
@@ -104,8 +105,9 @@ enterprise_data/tests/test_models.py,sha256=MWBY-LY5TPBjZ4GlvpM-h4W-BvRKr2Rml8Bz
|
|
104
105
|
enterprise_data/tests/test_utils.py,sha256=vbmYM7DMN-lHS2p4yaa0Yd6uSGXd2qoZRDE9X3J4Sec,18385
|
105
106
|
enterprise_data/tests/test_views.py,sha256=UvDRNTxruy5zBK_KgUy2cBMbwlaTW_vkM0-TCXbQZiY,69667
|
106
107
|
enterprise_data/tests/admin_analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
107
|
-
enterprise_data/tests/admin_analytics/mock_analytics_data.py,sha256=
|
108
|
+
enterprise_data/tests/admin_analytics/mock_analytics_data.py,sha256=LoYAh5cn_4rv2stGcMP5PELv2l7Eu2DTKG-v7viNTCs,20266
|
108
109
|
enterprise_data/tests/admin_analytics/mock_enrollments.py,sha256=LfuMo9Kn-OQD4z42G3BRuM5MXUUXXlaAMhTqfJf46XE,7266
|
110
|
+
enterprise_data/tests/admin_analytics/test_analytics_engagements.py,sha256=KPXtBPaAOrzfff7W-xERSGx9KtZAJndLbIJx3gopSnE,15689
|
109
111
|
enterprise_data/tests/admin_analytics/test_analytics_enrollments.py,sha256=UdKRkP6BNbsSo-gm0YCoddT-ReUMI1x9E6HNLSHT7pY,15177
|
110
112
|
enterprise_data/tests/admin_analytics/test_analytics_leaderboard.py,sha256=VSEyDAHfWBJvqmx9yzd4NnPAqK3TqaKrMBWswMAdzfU,6206
|
111
113
|
enterprise_data/tests/admin_analytics/test_data_loaders.py,sha256=o3denJ4aUS1pI5Crksl4C6m-NtCBm8ynoHBnLkf-v2U,4641
|
@@ -159,8 +161,8 @@ enterprise_reporting/tests/test_send_enterprise_reports.py,sha256=WtL-RqGgu2x5PP
|
|
159
161
|
enterprise_reporting/tests/test_utils.py,sha256=Zt_TA0LVb-B6fQGkUkAKKVlUKKnQh8jnw1US1jKe7g8,9493
|
160
162
|
enterprise_reporting/tests/test_vertica_client.py,sha256=-R2yNCGUjRtoXwLMBloVFQkFYrJoo613VCr61gwI3kQ,140
|
161
163
|
enterprise_reporting/tests/utils.py,sha256=xms2LM7DV3wczXEfctOK1ddel1EE0J_YSr17UzbCDy4,1401
|
162
|
-
edx_enterprise_data-8.
|
163
|
-
edx_enterprise_data-8.
|
164
|
-
edx_enterprise_data-8.
|
165
|
-
edx_enterprise_data-8.
|
166
|
-
edx_enterprise_data-8.
|
164
|
+
edx_enterprise_data-8.10.0.dist-info/LICENSE,sha256=dql8h4yceoMhuzlcK0TT_i-NgTFNIZsgE47Q4t3dUYI,34520
|
165
|
+
edx_enterprise_data-8.10.0.dist-info/METADATA,sha256=AYOfkH_4tZ4ANxQ--Pk_2jmmkxfbg0bRlBDaVA8YuSM,1570
|
166
|
+
edx_enterprise_data-8.10.0.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
|
167
|
+
edx_enterprise_data-8.10.0.dist-info/top_level.txt,sha256=f5F2kU-dob6MqiHJpgZkFzoCD5VMhsdpkTV5n9Tvq3I,59
|
168
|
+
edx_enterprise_data-8.10.0.dist-info/RECORD,,
|
enterprise_data/__init__.py
CHANGED
@@ -31,3 +31,11 @@ class ResponseType(Enum):
|
|
31
31
|
"""Response type choices"""
|
32
32
|
JSON = 'json'
|
33
33
|
CSV = 'csv'
|
34
|
+
|
35
|
+
|
36
|
+
class EngagementChart(Enum):
|
37
|
+
"""Response Choices"""
|
38
|
+
ENGAGEMENTS_OVER_TIME = 'engagements_over_time'
|
39
|
+
TOP_COURSES_BY_ENGAGEMENTS = 'top_courses_by_engagements'
|
40
|
+
TOP_SUBJECTS_BY_ENGAGEMENTS = 'top_subjects_by_engagements'
|
41
|
+
INDIVIDUAL_ENGAGEMENTS = 'individual_engagements'
|
@@ -5,7 +5,13 @@ from uuid import UUID
|
|
5
5
|
|
6
6
|
from rest_framework import serializers
|
7
7
|
|
8
|
-
from enterprise_data.admin_analytics.constants import
|
8
|
+
from enterprise_data.admin_analytics.constants import (
|
9
|
+
Calculation,
|
10
|
+
EngagementChart,
|
11
|
+
EnrollmentChart,
|
12
|
+
Granularity,
|
13
|
+
ResponseType,
|
14
|
+
)
|
9
15
|
from enterprise_data.models import (
|
10
16
|
EnterpriseAdminLearnerProgress,
|
11
17
|
EnterpriseAdminSummarizeInsights,
|
@@ -331,3 +337,27 @@ class AdvanceAnalyticsEnrollmentStatsSerializer(
|
|
331
337
|
if value not in self.CHART_TYPES:
|
332
338
|
raise serializers.ValidationError(f"chart_type must be one of {self.CHART_TYPES}")
|
333
339
|
return value
|
340
|
+
|
341
|
+
|
342
|
+
class AdvanceAnalyticsEngagementStatsSerializer(
|
343
|
+
AdvanceAnalyticsQueryParamSerializer
|
344
|
+
): # pylint: disable=abstract-method
|
345
|
+
"""Serializer for validating Advance Analytics Engagements Stats API"""
|
346
|
+
CHART_TYPES = [
|
347
|
+
EngagementChart.ENGAGEMENTS_OVER_TIME.value,
|
348
|
+
EngagementChart.TOP_COURSES_BY_ENGAGEMENTS.value,
|
349
|
+
EngagementChart.TOP_SUBJECTS_BY_ENGAGEMENTS.value
|
350
|
+
]
|
351
|
+
|
352
|
+
chart_type = serializers.CharField(required=False)
|
353
|
+
|
354
|
+
def validate_chart_type(self, value):
|
355
|
+
"""
|
356
|
+
Validate the chart_type value.
|
357
|
+
|
358
|
+
Raises:
|
359
|
+
serializers.ValidationError: If chart_type is not one of the valid choices
|
360
|
+
"""
|
361
|
+
if value not in self.CHART_TYPES:
|
362
|
+
raise serializers.ValidationError(f"chart_type must be one of {self.CHART_TYPES}")
|
363
|
+
return value
|
enterprise_data/api/v1/urls.py
CHANGED
@@ -11,6 +11,10 @@ from enterprise_data.api.v1.views import enterprise_admin as enterprise_admin_vi
|
|
11
11
|
from enterprise_data.api.v1.views import enterprise_completions as enterprise_completions_views
|
12
12
|
from enterprise_data.api.v1.views import enterprise_learner as enterprise_learner_views
|
13
13
|
from enterprise_data.api.v1.views import enterprise_offers as enterprise_offers_views
|
14
|
+
from enterprise_data.api.v1.views.analytics_engagements import (
|
15
|
+
AdvanceAnalyticsEngagementStatsView,
|
16
|
+
AdvanceAnalyticsIndividualEngagementsView,
|
17
|
+
)
|
14
18
|
from enterprise_data.api.v1.views.analytics_enrollments import (
|
15
19
|
AdvanceAnalyticsEnrollmentStatsView,
|
16
20
|
AdvanceAnalyticsIndividualEnrollmentsView,
|
@@ -73,6 +77,16 @@ urlpatterns = [
|
|
73
77
|
AdvanceAnalyticsIndividualEnrollmentsView.as_view(),
|
74
78
|
name='enterprise-admin-analytics-enrollments'
|
75
79
|
),
|
80
|
+
re_path(
|
81
|
+
fr'^admin/analytics/(?P<enterprise_uuid>{UUID4_REGEX})/engagements/stats$',
|
82
|
+
AdvanceAnalyticsEngagementStatsView.as_view(),
|
83
|
+
name='enterprise-admin-analytics-engagements-stats'
|
84
|
+
),
|
85
|
+
re_path(
|
86
|
+
fr'^admin/analytics/(?P<enterprise_uuid>{UUID4_REGEX})/engagements$',
|
87
|
+
AdvanceAnalyticsIndividualEngagementsView.as_view(),
|
88
|
+
name='enterprise-admin-analytics-engagements'
|
89
|
+
),
|
76
90
|
re_path(
|
77
91
|
fr'^admin/analytics/(?P<enterprise_id>{UUID4_REGEX})/skills/stats',
|
78
92
|
enterprise_admin_views.EnterpriseAdminAnalyticsSkillsView.as_view(),
|
@@ -0,0 +1,395 @@
|
|
1
|
+
"""Advance Analytics for Engagements"""
|
2
|
+
from datetime import datetime
|
3
|
+
|
4
|
+
from edx_rbac.decorators import permission_required
|
5
|
+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
6
|
+
from rest_framework import status as rest_status
|
7
|
+
from rest_framework.response import Response
|
8
|
+
from rest_framework.views import APIView
|
9
|
+
|
10
|
+
from django.http import HttpResponse, StreamingHttpResponse
|
11
|
+
|
12
|
+
from enterprise_data.admin_analytics.constants import Calculation, EngagementChart, Granularity, ResponseType
|
13
|
+
from enterprise_data.admin_analytics.utils import (
|
14
|
+
calculation_aggregation,
|
15
|
+
fetch_and_cache_engagements_data,
|
16
|
+
fetch_and_cache_enrollments_data,
|
17
|
+
fetch_enrollments_cache_expiry_timestamp,
|
18
|
+
granularity_aggregation,
|
19
|
+
)
|
20
|
+
from enterprise_data.api.v1 import serializers
|
21
|
+
from enterprise_data.api.v1.paginators import AdvanceAnalyticsPagination
|
22
|
+
from enterprise_data.renderers import IndividualEngagementsCSVRenderer
|
23
|
+
from enterprise_data.utils import date_filter
|
24
|
+
|
25
|
+
|
26
|
+
class AdvanceAnalyticsIndividualEngagementsView(APIView):
|
27
|
+
"""
|
28
|
+
API for getting the advance analytics individual engagements data.
|
29
|
+
"""
|
30
|
+
|
31
|
+
authentication_classes = (JwtAuthentication,)
|
32
|
+
pagination_class = AdvanceAnalyticsPagination
|
33
|
+
http_method_names = ["get"]
|
34
|
+
|
35
|
+
@permission_required('can_access_enterprise', fn=lambda request, enterprise_uuid: enterprise_uuid)
|
36
|
+
def get(self, request, enterprise_uuid):
|
37
|
+
"""
|
38
|
+
HTTP GET endpoint to retrieve the enterprise engagements data.
|
39
|
+
"""
|
40
|
+
serializer = serializers.AdvanceAnalyticsEngagementStatsSerializer(data=request.GET)
|
41
|
+
serializer.is_valid(raise_exception=True)
|
42
|
+
cache_expiry = fetch_enrollments_cache_expiry_timestamp()
|
43
|
+
|
44
|
+
enrollment_df = fetch_and_cache_enrollments_data(enterprise_uuid, cache_expiry).copy()
|
45
|
+
engagement_df = fetch_and_cache_engagements_data(enterprise_uuid, cache_expiry).copy()
|
46
|
+
# Use start and end date if provided by the client, if client has not provided then use
|
47
|
+
# 1. minimum enrollment date from the data as the start_date
|
48
|
+
# 2. today's date as the end_date
|
49
|
+
start_date = serializer.data.get('start_date', enrollment_df.enterprise_enrollment_date.min())
|
50
|
+
end_date = serializer.data.get('end_date', datetime.now())
|
51
|
+
response_type = request.query_params.get('response_type', ResponseType.JSON.value)
|
52
|
+
# Date filtering.
|
53
|
+
engagements = date_filter(
|
54
|
+
start=start_date, end=end_date, data_frame=engagement_df.copy(), date_column='activity_date'
|
55
|
+
)
|
56
|
+
engagements["learning_time_hours"] = engagements["learning_time_seconds"] / 60 / 60
|
57
|
+
engagements = engagements[engagements["learning_time_hours"] > 0]
|
58
|
+
engagements["learning_time_hours"] = round(engagements["learning_time_hours"].astype(float), 1)
|
59
|
+
|
60
|
+
# Select only the columns that will be in the table.
|
61
|
+
engagements = engagements[
|
62
|
+
[
|
63
|
+
"email",
|
64
|
+
"course_title",
|
65
|
+
"activity_date",
|
66
|
+
"course_subject",
|
67
|
+
"learning_time_hours",
|
68
|
+
]
|
69
|
+
]
|
70
|
+
engagements["activity_date"] = engagements["activity_date"].dt.date
|
71
|
+
engagements = engagements.sort_values(by="activity_date", ascending=False).reset_index(drop=True)
|
72
|
+
if response_type == ResponseType.CSV.value:
|
73
|
+
response = StreamingHttpResponse(
|
74
|
+
IndividualEngagementsCSVRenderer().render(self._stream_serialized_data(engagements)),
|
75
|
+
content_type="text/csv"
|
76
|
+
)
|
77
|
+
start_date = start_date.strftime('%Y/%m/%d')
|
78
|
+
end_date = end_date.strftime('%Y/%m/%d')
|
79
|
+
filename = f"""Individual Engagements, {start_date} - {end_date}.csv"""
|
80
|
+
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
81
|
+
response['Access-Control-Expose-Headers'] = 'Content-Disposition'
|
82
|
+
return response
|
83
|
+
|
84
|
+
paginator = self.pagination_class()
|
85
|
+
page = paginator.paginate_queryset(engagements, request)
|
86
|
+
serialized_data = page.data.to_dict(orient='records')
|
87
|
+
response = paginator.get_paginated_response(serialized_data)
|
88
|
+
|
89
|
+
return response
|
90
|
+
|
91
|
+
def _stream_serialized_data(self, engagements, chunk_size=50000):
|
92
|
+
"""
|
93
|
+
Stream the serialized data.
|
94
|
+
"""
|
95
|
+
total_rows = engagements.shape[0]
|
96
|
+
for start_index in range(0, total_rows, chunk_size):
|
97
|
+
end_index = min(start_index + chunk_size, total_rows)
|
98
|
+
chunk = engagements.iloc[start_index:end_index]
|
99
|
+
yield from chunk.to_dict(orient='records')
|
100
|
+
|
101
|
+
|
102
|
+
class AdvanceAnalyticsEngagementStatsView(APIView):
|
103
|
+
"""
|
104
|
+
API for getting the advance analytics engagements statistics data.
|
105
|
+
"""
|
106
|
+
|
107
|
+
authentication_classes = (JwtAuthentication,)
|
108
|
+
http_method_names = ["get"]
|
109
|
+
|
110
|
+
@permission_required('can_access_enterprise', fn=lambda request, enterprise_uuid: enterprise_uuid)
|
111
|
+
def get(self, request, enterprise_uuid):
|
112
|
+
"""
|
113
|
+
HTTP GET endpoint to retrieve the enterprise engagements statistics data.
|
114
|
+
"""
|
115
|
+
serializer = serializers.AdvanceAnalyticsEngagementStatsSerializer(data=request.GET)
|
116
|
+
serializer.is_valid(raise_exception=True)
|
117
|
+
|
118
|
+
cache_expiry = fetch_enrollments_cache_expiry_timestamp()
|
119
|
+
|
120
|
+
enrollment_df = fetch_and_cache_enrollments_data(enterprise_uuid, cache_expiry).copy()
|
121
|
+
engagement_df = fetch_and_cache_engagements_data(enterprise_uuid, cache_expiry).copy()
|
122
|
+
# Use start and end date if provided by the client, if client has not provided then use
|
123
|
+
# 1. minimum enrollment date from the data as the start_date
|
124
|
+
# 2. today's date as the end_date
|
125
|
+
start_date = serializer.data.get('start_date', enrollment_df.enterprise_enrollment_date.min())
|
126
|
+
end_date = serializer.data.get('end_date', datetime.now())
|
127
|
+
granularity = serializer.data.get('granularity', Granularity.DAILY.value)
|
128
|
+
calculation = serializer.data.get('calculation', Calculation.TOTAL.value)
|
129
|
+
chart_type = serializer.data.get('chart_type')
|
130
|
+
|
131
|
+
if chart_type is None:
|
132
|
+
data = {
|
133
|
+
"engagements_over_time": self.construct_engagements_over_time(
|
134
|
+
engagement_df.copy(),
|
135
|
+
start_date,
|
136
|
+
end_date,
|
137
|
+
granularity,
|
138
|
+
calculation,
|
139
|
+
),
|
140
|
+
"top_courses_by_engagement": self.construct_top_courses_by_engagements(
|
141
|
+
engagement_df.copy(),
|
142
|
+
start_date,
|
143
|
+
end_date,
|
144
|
+
),
|
145
|
+
"top_subjects_by_engagement": self.construct_top_subjects_by_engagements(
|
146
|
+
engagement_df.copy(),
|
147
|
+
start_date,
|
148
|
+
end_date,
|
149
|
+
),
|
150
|
+
}
|
151
|
+
return Response(data)
|
152
|
+
elif chart_type == EngagementChart.ENGAGEMENTS_OVER_TIME.value:
|
153
|
+
return self.construct_engagements_over_time_csv(
|
154
|
+
engagement_df.copy(),
|
155
|
+
start_date,
|
156
|
+
end_date,
|
157
|
+
granularity,
|
158
|
+
calculation,
|
159
|
+
)
|
160
|
+
elif chart_type == EngagementChart.TOP_COURSES_BY_ENGAGEMENTS.value:
|
161
|
+
return self.construct_top_courses_by_engagements_csv(
|
162
|
+
engagement_df.copy(),
|
163
|
+
start_date,
|
164
|
+
end_date,
|
165
|
+
)
|
166
|
+
elif chart_type == EngagementChart.TOP_SUBJECTS_BY_ENGAGEMENTS.value:
|
167
|
+
return self.construct_top_subjects_by_engagements_csv(
|
168
|
+
engagement_df.copy(),
|
169
|
+
start_date,
|
170
|
+
end_date,
|
171
|
+
)
|
172
|
+
return Response(data='Not Found', status=rest_status.HTTP_400_BAD_REQUEST)
|
173
|
+
|
174
|
+
def engagements_over_time_common(self, engagements_df, start_date, end_date, granularity, calculation):
|
175
|
+
"""
|
176
|
+
Common method for constructing engagements over time data.
|
177
|
+
|
178
|
+
Arguments:
|
179
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
180
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
181
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
182
|
+
granularity {str} -- Granularity of the data. One of Granularity choices
|
183
|
+
calculation {str} -- Calculation of the data. One of Calculation choices
|
184
|
+
"""
|
185
|
+
engagements_df["learning_time_hours"] = engagements_df["learning_time_seconds"] / 60 / 60
|
186
|
+
engagements_df = engagements_df[engagements_df["learning_time_hours"] > 0]
|
187
|
+
engagements_df["learning_time_hours"] = round(engagements_df["learning_time_hours"].astype(float), 1)
|
188
|
+
|
189
|
+
engagements_df = engagements_df[["activity_date", "enroll_type", "learning_time_hours"]]
|
190
|
+
|
191
|
+
# Date filtering.
|
192
|
+
engagements_df = date_filter(
|
193
|
+
start=start_date, end=end_date, data_frame=engagements_df, date_column="activity_date"
|
194
|
+
)
|
195
|
+
|
196
|
+
# Date aggregation.
|
197
|
+
engagements_df = granularity_aggregation(
|
198
|
+
level=granularity,
|
199
|
+
group=["activity_date", "enroll_type"],
|
200
|
+
date="activity_date",
|
201
|
+
data_frame=engagements_df,
|
202
|
+
aggregation_type="sum",
|
203
|
+
)
|
204
|
+
|
205
|
+
# Calculating metric.
|
206
|
+
engagements = calculation_aggregation(calc=calculation, aggregation_type="sum", data_frame=engagements_df)
|
207
|
+
return engagements
|
208
|
+
|
209
|
+
def construct_engagements_over_time(self, engagements_df, start_date, end_date, granularity, calculation):
|
210
|
+
"""
|
211
|
+
Construct engagements over time data.
|
212
|
+
|
213
|
+
Arguments:
|
214
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
215
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
216
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
217
|
+
granularity {str} -- Granularity of the data. One of Granularity choices
|
218
|
+
calculation {str} -- Calculation of the data. One of Calculation choices
|
219
|
+
"""
|
220
|
+
engagements = self.engagements_over_time_common(engagements_df, start_date, end_date, granularity, calculation)
|
221
|
+
# convert dataframe to a list of records
|
222
|
+
return engagements.to_dict(orient='records')
|
223
|
+
|
224
|
+
def construct_engagements_over_time_csv(self, engagements_df, start_date, end_date, granularity, calculation):
|
225
|
+
"""
|
226
|
+
Construct engagements over time CSV.
|
227
|
+
|
228
|
+
Arguments:
|
229
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
230
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
231
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
232
|
+
granularity {str} -- Granularity of the data. One of Granularity choices
|
233
|
+
calculation {str} -- Calculation of the data. One of Calculation choices
|
234
|
+
"""
|
235
|
+
engagements = self.engagements_over_time_common(engagements_df, start_date, end_date, granularity, calculation)
|
236
|
+
|
237
|
+
engagements = engagements.pivot(
|
238
|
+
index="activity_date", columns="enroll_type", values="sum"
|
239
|
+
)
|
240
|
+
|
241
|
+
filename = f"Engagement Timeseries, {start_date} - {end_date} ({granularity} {calculation}).csv"
|
242
|
+
return self.construct_csv_response(engagements, filename)
|
243
|
+
|
244
|
+
def top_courses_by_engagements_common(self, engagements_df, start_date, end_date):
|
245
|
+
"""
|
246
|
+
Common method for constructing top courses by engagements data.
|
247
|
+
|
248
|
+
Arguments:
|
249
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
250
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
251
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
252
|
+
group_by_columns {list} -- List of columns to group by
|
253
|
+
columns {list} -- List of column for the final result
|
254
|
+
"""
|
255
|
+
engagements_df["learning_time_hours"] = engagements_df["learning_time_seconds"] / 60 / 60
|
256
|
+
engagements_df["learning_time_hours"] = engagements_df["learning_time_hours"].astype("float")
|
257
|
+
|
258
|
+
# Date filtering.
|
259
|
+
engagements = date_filter(
|
260
|
+
start=start_date, end=end_date, data_frame=engagements_df, date_column="activity_date"
|
261
|
+
)
|
262
|
+
|
263
|
+
courses = list(
|
264
|
+
engagements.groupby(["course_key"])
|
265
|
+
.learning_time_hours.sum()
|
266
|
+
.sort_values(ascending=False)[:10]
|
267
|
+
.index
|
268
|
+
)
|
269
|
+
|
270
|
+
engagements = (
|
271
|
+
engagements_df[engagements_df.course_key.isin(courses)]
|
272
|
+
.groupby(["course_key", "course_title", "enroll_type"])
|
273
|
+
.learning_time_hours.sum()
|
274
|
+
.reset_index()
|
275
|
+
)
|
276
|
+
|
277
|
+
engagements.columns = ["course_key", "course_title", "enroll_type", "count"]
|
278
|
+
|
279
|
+
return engagements
|
280
|
+
|
281
|
+
def construct_top_courses_by_engagements(self, engagements_df, start_date, end_date):
|
282
|
+
"""
|
283
|
+
Construct top courses by engagements data.
|
284
|
+
|
285
|
+
Arguments:
|
286
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
287
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
288
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
289
|
+
"""
|
290
|
+
engagements = self.top_courses_by_engagements_common(
|
291
|
+
engagements_df,
|
292
|
+
start_date,
|
293
|
+
end_date
|
294
|
+
)
|
295
|
+
|
296
|
+
# convert dataframe to a list of records
|
297
|
+
return engagements.to_dict(orient='records')
|
298
|
+
|
299
|
+
def construct_top_courses_by_engagements_csv(self, engagements_df, start_date, end_date):
|
300
|
+
"""
|
301
|
+
Construct top courses by engagements CSV.
|
302
|
+
|
303
|
+
Arguments:
|
304
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
305
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
306
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
307
|
+
"""
|
308
|
+
engagements = self.top_courses_by_engagements_common(
|
309
|
+
engagements_df,
|
310
|
+
start_date,
|
311
|
+
end_date
|
312
|
+
)
|
313
|
+
|
314
|
+
engagements = engagements.pivot(
|
315
|
+
index=["course_key", "course_title"], columns="enroll_type", values="count"
|
316
|
+
)
|
317
|
+
|
318
|
+
filename = f"Top 10 Courses by Learning Hours, {start_date} - {end_date}.csv"
|
319
|
+
return self.construct_csv_response(engagements, filename)
|
320
|
+
|
321
|
+
def top_subjects_by_engagements_common(self, engagements_df, start_date, end_date):
|
322
|
+
"""
|
323
|
+
Common method for constructing top subjects by engagements data.
|
324
|
+
|
325
|
+
Arguments:
|
326
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
327
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
328
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
329
|
+
"""
|
330
|
+
engagements_df["learning_time_hours"] = engagements_df["learning_time_seconds"] / 60 / 60
|
331
|
+
engagements_df["learning_time_hours"] = engagements_df["learning_time_hours"].astype("float")
|
332
|
+
|
333
|
+
# Date filtering.
|
334
|
+
engagements = date_filter(
|
335
|
+
start=start_date, end=end_date, data_frame=engagements_df, date_column="activity_date"
|
336
|
+
)
|
337
|
+
|
338
|
+
subjects = list(
|
339
|
+
engagements.groupby(["course_subject"])
|
340
|
+
.learning_time_hours.sum()
|
341
|
+
.sort_values(ascending=False)[:10]
|
342
|
+
.index
|
343
|
+
)
|
344
|
+
|
345
|
+
engagements = (
|
346
|
+
engagements[engagements.course_subject.isin(subjects)]
|
347
|
+
.groupby(["course_subject", "enroll_type"])
|
348
|
+
.learning_time_hours.sum()
|
349
|
+
.reset_index()
|
350
|
+
)
|
351
|
+
engagements.columns = ["course_subject", "enroll_type", "count"]
|
352
|
+
|
353
|
+
return engagements
|
354
|
+
|
355
|
+
def construct_top_subjects_by_engagements(self, engagements_df, start_date, end_date):
|
356
|
+
"""
|
357
|
+
Construct top subjects by engagements data.
|
358
|
+
|
359
|
+
Arguments:
|
360
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
361
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
362
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
363
|
+
"""
|
364
|
+
engagements = self.top_subjects_by_engagements_common(engagements_df, start_date, end_date)
|
365
|
+
# convert dataframe to a list of records
|
366
|
+
return engagements.to_dict(orient='records')
|
367
|
+
|
368
|
+
def construct_top_subjects_by_engagements_csv(self, engagements_df, start_date, end_date):
|
369
|
+
"""
|
370
|
+
Construct top subjects by engagements CSV.
|
371
|
+
|
372
|
+
Arguments:
|
373
|
+
engagements_df {DataFrame} -- DataFrame of engagements
|
374
|
+
start_date {datetime} -- Engagement start date in the format 'YYYY-MM-DD'
|
375
|
+
end_date {datetime} -- Engagement end date in the format 'YYYY-MM-DD'
|
376
|
+
"""
|
377
|
+
engagements = self.top_subjects_by_engagements_common(engagements_df, start_date, end_date)
|
378
|
+
engagements = engagements.pivot(index="course_subject", columns="enroll_type", values="count")
|
379
|
+
filename = f"Top 10 Subjects by Learning Hours, {start_date} - {end_date}.csv"
|
380
|
+
return self.construct_csv_response(engagements, filename)
|
381
|
+
|
382
|
+
def construct_csv_response(self, engagements, filename):
|
383
|
+
"""
|
384
|
+
Construct CSV response.
|
385
|
+
|
386
|
+
Arguments:
|
387
|
+
engagements {DataFrame} -- DataFrame of engagements
|
388
|
+
filename {str} -- Filename for the CSV
|
389
|
+
"""
|
390
|
+
response = HttpResponse(content_type='text/csv')
|
391
|
+
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
392
|
+
response['Access-Control-Expose-Headers'] = 'Content-Disposition'
|
393
|
+
engagements.to_csv(path_or_buf=response)
|
394
|
+
|
395
|
+
return response
|
@@ -382,6 +382,7 @@ class AdvanceAnalyticsEnrollmentStatsView(APIView):
|
|
382
382
|
"""
|
383
383
|
response = HttpResponse(content_type='text/csv')
|
384
384
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
385
|
+
response['Access-Control-Expose-Headers'] = 'Content-Disposition'
|
385
386
|
enrollments.to_csv(path_or_buf=response)
|
386
387
|
|
387
388
|
return response
|
@@ -120,7 +120,10 @@ class AdvanceAnalyticsLeaderboardView(APIView):
|
|
120
120
|
return StreamingHttpResponse(
|
121
121
|
LeaderboardCSVRenderer().render(self._stream_serialized_data(leaderboard_df)),
|
122
122
|
content_type="text/csv",
|
123
|
-
headers={
|
123
|
+
headers={
|
124
|
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
125
|
+
"Access-Control-Expose-Headers": "Content-Disposition"
|
126
|
+
},
|
124
127
|
)
|
125
128
|
|
126
129
|
paginator = self.pagination_class()
|
@@ -208,6 +208,7 @@ class EnterpriseAdminAnalyticsSkillsView(APIView):
|
|
208
208
|
response = HttpResponse(content_type='text/csv')
|
209
209
|
filename = f"Skills by Enrollment and Completion, {start_date} - {end_date}.csv"
|
210
210
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
211
|
+
response['Access-Control-Expose-Headers'] = 'Content-Disposition'
|
211
212
|
csv_data.to_csv(path_or_buf=response, index=False)
|
212
213
|
return response
|
213
214
|
|
@@ -90,6 +90,7 @@ class EnterrpiseAdminCompletionsStatsView(APIView):
|
|
90
90
|
)
|
91
91
|
filename = csv_data['filename']
|
92
92
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
93
|
+
response['Access-Control-Expose-Headers'] = 'Content-Disposition'
|
93
94
|
csv_data['data'].to_csv(path_or_buf=response)
|
94
95
|
return response
|
95
96
|
|
@@ -187,6 +188,7 @@ class EnterrpiseAdminCompletionsView(APIView):
|
|
187
188
|
response = HttpResponse(content_type='text/csv')
|
188
189
|
filename = f"Individual Completions, {start_date} - {end_date}.csv"
|
189
190
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
191
|
+
response['Access-Control-Expose-Headers'] = 'Content-Disposition'
|
190
192
|
dff.to_csv(path_or_buf=response, index=False)
|
191
193
|
return response
|
192
194
|
|
enterprise_data/renderers.py
CHANGED
@@ -57,3 +57,17 @@ class LeaderboardCSVRenderer(CSVStreamingRenderer):
|
|
57
57
|
'average_session_length',
|
58
58
|
'course_completions',
|
59
59
|
]
|
60
|
+
|
61
|
+
|
62
|
+
class IndividualEngagementsCSVRenderer(CSVStreamingRenderer):
|
63
|
+
"""
|
64
|
+
Custom streaming csv renderer for advance analytics individual engagements data.
|
65
|
+
"""
|
66
|
+
|
67
|
+
header = [
|
68
|
+
'email',
|
69
|
+
'course_title',
|
70
|
+
'activity_date',
|
71
|
+
'course_subject',
|
72
|
+
'learning_time_hours',
|
73
|
+
]
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
import pandas as pd
|
4
4
|
|
5
|
-
from enterprise_data.admin_analytics.constants import EnrollmentChart
|
5
|
+
from enterprise_data.admin_analytics.constants import EngagementChart, EnrollmentChart
|
6
6
|
from enterprise_data.admin_analytics.utils import ChartType
|
7
7
|
|
8
8
|
ENROLLMENTS = [
|
@@ -339,6 +339,30 @@ ENROLLMENT_STATS_CSVS = {
|
|
339
339
|
)
|
340
340
|
}
|
341
341
|
|
342
|
+
ENGAGEMENT_STATS_CSVS = {
|
343
|
+
EngagementChart.ENGAGEMENTS_OVER_TIME.value: (
|
344
|
+
b'activity_date,certificate\n'
|
345
|
+
b'2021-07-19,0.0\n'
|
346
|
+
b'2021-07-26,4.4\n'
|
347
|
+
b'2021-07-27,1.2\n'
|
348
|
+
b'2021-08-05,3.6\n'
|
349
|
+
b'2021-08-21,2.7\n'
|
350
|
+
b'2021-09-02,1.3\n'
|
351
|
+
b'2021-09-21,1.5\n'
|
352
|
+
b'2022-05-17,0.0\n'
|
353
|
+
),
|
354
|
+
EngagementChart.TOP_COURSES_BY_ENGAGEMENTS.value: (
|
355
|
+
b'course_key,course_title,certificate\n'
|
356
|
+
b'Kcpr+XoR30,Assimilated even-keeled focus group,0.0\n'
|
357
|
+
b'luGg+KNt30,Synergized reciprocal encoding,14.786944444444444\n'
|
358
|
+
),
|
359
|
+
EngagementChart.TOP_SUBJECTS_BY_ENGAGEMENTS.value: (
|
360
|
+
b'course_subject,certificate\n'
|
361
|
+
b'business-management,14.786944444444444\n'
|
362
|
+
b'engineering,0.0\n'
|
363
|
+
)
|
364
|
+
}
|
365
|
+
|
342
366
|
|
343
367
|
def enrollments_dataframe():
|
344
368
|
"""Return a DataFrame of enrollments."""
|
@@ -509,3 +533,19 @@ COMPLETIONS_STATS_CSVS = {
|
|
509
533
|
b'business-management,2\n'
|
510
534
|
)
|
511
535
|
}
|
536
|
+
|
537
|
+
|
538
|
+
def engagements_csv_content():
|
539
|
+
"""Return the CSV content of engagements."""
|
540
|
+
return (
|
541
|
+
b'email,course_title,activity_date,course_subject,learning_time_hours\r\n'
|
542
|
+
b'graceperez@example.com,Synergized reciprocal encoding,2022-05-17,business-management,0.0\r\n'
|
543
|
+
b'webertodd@example.com,Synergized reciprocal encoding,2021-09-21,business-management,1.5\r\n'
|
544
|
+
b'yferguson@example.net,Synergized reciprocal encoding,2021-09-02,business-management,1.3\r\n'
|
545
|
+
b'seth57@example.org,Synergized reciprocal encoding,2021-08-21,business-management,2.7\r\n'
|
546
|
+
b'padillamichelle@example.org,Synergized reciprocal encoding,2021-08-05,business-management,1.0\r\n'
|
547
|
+
b'weaverpatricia@example.net,Synergized reciprocal encoding,2021-08-05,business-management,2.6\r\n'
|
548
|
+
b'yallison@example.org,Synergized reciprocal encoding,2021-07-27,business-management,1.2\r\n'
|
549
|
+
b'paul77@example.org,Synergized reciprocal encoding,2021-07-26,business-management,4.4\r\n'
|
550
|
+
b'samanthaclarke@example.org,Synergized reciprocal encoding,2021-07-19,business-management,0.0\r\n'
|
551
|
+
)
|
@@ -0,0 +1,390 @@
|
|
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 EngagementChart, ResponseType
|
12
|
+
from enterprise_data.api.v1.serializers import AdvanceAnalyticsEngagementStatsSerializer as EngagementSerializer
|
13
|
+
from enterprise_data.tests.admin_analytics.mock_analytics_data import (
|
14
|
+
ENGAGEMENT_STATS_CSVS,
|
15
|
+
ENGAGEMENTS,
|
16
|
+
engagements_csv_content,
|
17
|
+
engagements_dataframe,
|
18
|
+
enrollments_dataframe,
|
19
|
+
)
|
20
|
+
from enterprise_data.tests.mixins import JWTTestMixin
|
21
|
+
from enterprise_data.tests.test_utils import UserFactory
|
22
|
+
from enterprise_data_roles.constants import ENTERPRISE_DATA_ADMIN_ROLE
|
23
|
+
from enterprise_data_roles.models import EnterpriseDataFeatureRole, EnterpriseDataRoleAssignment
|
24
|
+
|
25
|
+
INVALID_CALCULATION_ERROR = (
|
26
|
+
f"Calculation must be one of {EngagementSerializer.CALCULATION_CHOICES}"
|
27
|
+
)
|
28
|
+
INVALID_GRANULARITY_ERROR = (
|
29
|
+
f"Granularity must be one of {EngagementSerializer.GRANULARITY_CHOICES}"
|
30
|
+
)
|
31
|
+
INVALID_CSV_ERROR1 = f"chart_type must be one of {EngagementSerializer.CHART_TYPES}"
|
32
|
+
|
33
|
+
|
34
|
+
@ddt.ddt
|
35
|
+
class TestIndividualEngagementsAPI(JWTTestMixin, APITransactionTestCase):
|
36
|
+
"""Tests for AdvanceAnalyticsIndividualEngagementsView."""
|
37
|
+
|
38
|
+
def setUp(self):
|
39
|
+
"""
|
40
|
+
Setup method.
|
41
|
+
"""
|
42
|
+
super().setUp()
|
43
|
+
self.user = UserFactory(is_staff=True)
|
44
|
+
role, __ = EnterpriseDataFeatureRole.objects.get_or_create(
|
45
|
+
name=ENTERPRISE_DATA_ADMIN_ROLE
|
46
|
+
)
|
47
|
+
self.role_assignment = EnterpriseDataRoleAssignment.objects.create(
|
48
|
+
role=role, user=self.user
|
49
|
+
)
|
50
|
+
self.client.force_authenticate(user=self.user)
|
51
|
+
|
52
|
+
self.enterprise_uuid = "ee5e6b3a-069a-4947-bb8d-d2dbc323396c"
|
53
|
+
self.set_jwt_cookie()
|
54
|
+
|
55
|
+
self.url = reverse(
|
56
|
+
"v1:enterprise-admin-analytics-engagements",
|
57
|
+
kwargs={"enterprise_uuid": self.enterprise_uuid},
|
58
|
+
)
|
59
|
+
|
60
|
+
fetch_max_enrollment_datetime_patcher = patch(
|
61
|
+
'enterprise_data.admin_analytics.utils.fetch_max_enrollment_datetime',
|
62
|
+
return_value=datetime.now()
|
63
|
+
)
|
64
|
+
|
65
|
+
fetch_max_enrollment_datetime_patcher.start()
|
66
|
+
self.addCleanup(fetch_max_enrollment_datetime_patcher.stop)
|
67
|
+
|
68
|
+
def verify_engagement_data(self, results, results_count):
|
69
|
+
"""Verify the received engagement data."""
|
70
|
+
attrs = [
|
71
|
+
"email",
|
72
|
+
"course_title",
|
73
|
+
"activity_date",
|
74
|
+
"course_subject",
|
75
|
+
]
|
76
|
+
|
77
|
+
assert len(results) == results_count
|
78
|
+
|
79
|
+
filtered_data = []
|
80
|
+
for engagement in ENGAGEMENTS:
|
81
|
+
for result in results:
|
82
|
+
if engagement["email"] == result["email"]:
|
83
|
+
data = {attr: engagement[attr] for attr in attrs}
|
84
|
+
data["learning_time_hours"] = round(engagement["learning_time_seconds"] / 3600, 1)
|
85
|
+
filtered_data.append(data)
|
86
|
+
break
|
87
|
+
|
88
|
+
received_data = sorted(results, key=lambda x: x["email"])
|
89
|
+
expected_data = sorted(filtered_data, key=lambda x: x["email"])
|
90
|
+
assert received_data == expected_data
|
91
|
+
|
92
|
+
@patch(
|
93
|
+
"enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_enrollments_data"
|
94
|
+
)
|
95
|
+
@patch(
|
96
|
+
"enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_engagements_data"
|
97
|
+
)
|
98
|
+
def test_get(self, mock_fetch_and_cache_engagements_data, mock_fetch_and_cache_enrollments_data):
|
99
|
+
"""
|
100
|
+
Test the GET method for the AdvanceAnalyticsIndividualEngagementsView works.
|
101
|
+
"""
|
102
|
+
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
103
|
+
mock_fetch_and_cache_engagements_data.return_value = engagements_dataframe()
|
104
|
+
|
105
|
+
response = self.client.get(self.url, {"page_size": 2})
|
106
|
+
assert response.status_code == status.HTTP_200_OK
|
107
|
+
data = response.json()
|
108
|
+
assert data["next"] == f"http://testserver{self.url}?page=2&page_size=2"
|
109
|
+
assert data["previous"] is None
|
110
|
+
assert data["current_page"] == 1
|
111
|
+
assert data["num_pages"] == 5
|
112
|
+
assert data["count"] == 9
|
113
|
+
self.verify_engagement_data(data["results"], 2)
|
114
|
+
|
115
|
+
response = self.client.get(self.url, {"page_size": 2, "page": 2})
|
116
|
+
assert response.status_code == status.HTTP_200_OK
|
117
|
+
data = response.json()
|
118
|
+
assert data["next"] == f"http://testserver{self.url}?page=3&page_size=2"
|
119
|
+
assert data["previous"] == f"http://testserver{self.url}?page_size=2"
|
120
|
+
assert data["current_page"] == 2
|
121
|
+
assert data["num_pages"] == 5
|
122
|
+
assert data["count"] == 9
|
123
|
+
self.verify_engagement_data(data["results"], 2)
|
124
|
+
|
125
|
+
response = self.client.get(self.url, {"page_size": 2, "page": 5})
|
126
|
+
assert response.status_code == status.HTTP_200_OK
|
127
|
+
data = response.json()
|
128
|
+
assert data["next"] is None
|
129
|
+
assert data["previous"] == f"http://testserver{self.url}?page=4&page_size=2"
|
130
|
+
assert data["current_page"] == 5
|
131
|
+
assert data["num_pages"] == 5
|
132
|
+
assert data["count"] == 9
|
133
|
+
self.verify_engagement_data(data["results"], 1)
|
134
|
+
|
135
|
+
response = self.client.get(self.url, {"page_size": 9})
|
136
|
+
assert response.status_code == status.HTTP_200_OK
|
137
|
+
data = response.json()
|
138
|
+
assert data["next"] is None
|
139
|
+
assert data["previous"] is None
|
140
|
+
assert data["current_page"] == 1
|
141
|
+
assert data["num_pages"] == 1
|
142
|
+
assert data["count"] == 9
|
143
|
+
self.verify_engagement_data(data["results"], 9)
|
144
|
+
|
145
|
+
@patch(
|
146
|
+
"enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_enrollments_data"
|
147
|
+
)
|
148
|
+
@patch(
|
149
|
+
"enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_engagements_data"
|
150
|
+
)
|
151
|
+
def test_get_csv(self, mock_fetch_and_cache_engagements_data, mock_fetch_and_cache_enrollments_data):
|
152
|
+
"""
|
153
|
+
Test the GET method for the AdvanceAnalyticsIndividualEngagementsView return correct CSV data.
|
154
|
+
"""
|
155
|
+
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
156
|
+
mock_fetch_and_cache_engagements_data.return_value = engagements_dataframe()
|
157
|
+
start_date = enrollments_dataframe().enterprise_enrollment_date.min().strftime('%Y/%m/%d')
|
158
|
+
end_date = datetime.now().strftime('%Y/%m/%d')
|
159
|
+
response = self.client.get(self.url, {"response_type": ResponseType.CSV.value})
|
160
|
+
assert response.status_code == status.HTTP_200_OK
|
161
|
+
|
162
|
+
# verify the response headers
|
163
|
+
assert response["Content-Type"] == "text/csv"
|
164
|
+
filename = f"""Individual Engagements, {start_date} - {end_date}.csv"""
|
165
|
+
assert (
|
166
|
+
response["Content-Disposition"] == f'attachment; filename="{filename}"'
|
167
|
+
)
|
168
|
+
|
169
|
+
# verify the response content
|
170
|
+
content = b"".join(response.streaming_content)
|
171
|
+
assert content == engagements_csv_content()
|
172
|
+
|
173
|
+
@ddt.data(
|
174
|
+
{
|
175
|
+
"params": {"start_date": 1},
|
176
|
+
"error": {
|
177
|
+
"start_date": [
|
178
|
+
"Date has wrong format. Use one of these formats instead: YYYY-MM-DD."
|
179
|
+
]
|
180
|
+
},
|
181
|
+
},
|
182
|
+
{
|
183
|
+
"params": {"end_date": 2},
|
184
|
+
"error": {
|
185
|
+
"end_date": [
|
186
|
+
"Date has wrong format. Use one of these formats instead: YYYY-MM-DD."
|
187
|
+
]
|
188
|
+
},
|
189
|
+
},
|
190
|
+
{
|
191
|
+
"params": {"start_date": "2024-01-01", "end_date": "2023-01-01"},
|
192
|
+
"error": {
|
193
|
+
"non_field_errors": [
|
194
|
+
"start_date should be less than or equal to end_date."
|
195
|
+
]
|
196
|
+
},
|
197
|
+
},
|
198
|
+
{
|
199
|
+
"params": {"calculation": "invalid"},
|
200
|
+
"error": {"calculation": [INVALID_CALCULATION_ERROR]},
|
201
|
+
},
|
202
|
+
{
|
203
|
+
"params": {"granularity": "invalid"},
|
204
|
+
"error": {"granularity": [INVALID_GRANULARITY_ERROR]},
|
205
|
+
},
|
206
|
+
{"params": {"chart_type": "invalid"}, "error": {"chart_type": [INVALID_CSV_ERROR1]}},
|
207
|
+
)
|
208
|
+
@ddt.unpack
|
209
|
+
def test_get_invalid_query_params(self, params, error):
|
210
|
+
"""
|
211
|
+
Test the GET method return correct error if any query param value is incorrect.
|
212
|
+
"""
|
213
|
+
response = self.client.get(self.url, params)
|
214
|
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
215
|
+
assert response.json() == error
|
216
|
+
|
217
|
+
|
218
|
+
@ddt.ddt
|
219
|
+
class TestEngagementStatsAPI(JWTTestMixin, APITransactionTestCase):
|
220
|
+
"""Tests for AdvanceAnalyticsEngagementStatsView."""
|
221
|
+
|
222
|
+
def setUp(self):
|
223
|
+
"""
|
224
|
+
Setup method.
|
225
|
+
"""
|
226
|
+
super().setUp()
|
227
|
+
self.user = UserFactory(is_staff=True)
|
228
|
+
role, __ = EnterpriseDataFeatureRole.objects.get_or_create(
|
229
|
+
name=ENTERPRISE_DATA_ADMIN_ROLE
|
230
|
+
)
|
231
|
+
self.role_assignment = EnterpriseDataRoleAssignment.objects.create(
|
232
|
+
role=role, user=self.user
|
233
|
+
)
|
234
|
+
self.client.force_authenticate(user=self.user)
|
235
|
+
|
236
|
+
self.enterprise_uuid = "ee5e6b3a-069a-4947-bb8d-d2dbc323396c"
|
237
|
+
self.set_jwt_cookie()
|
238
|
+
|
239
|
+
self.url = reverse(
|
240
|
+
"v1:enterprise-admin-analytics-engagements-stats",
|
241
|
+
kwargs={"enterprise_uuid": self.enterprise_uuid},
|
242
|
+
)
|
243
|
+
|
244
|
+
fetch_max_enrollment_datetime_patcher = patch(
|
245
|
+
'enterprise_data.admin_analytics.utils.fetch_max_enrollment_datetime',
|
246
|
+
return_value=datetime.now()
|
247
|
+
)
|
248
|
+
|
249
|
+
fetch_max_enrollment_datetime_patcher.start()
|
250
|
+
self.addCleanup(fetch_max_enrollment_datetime_patcher.stop)
|
251
|
+
|
252
|
+
def verify_engagement_data(self, results):
|
253
|
+
"""Verify the received engagement data."""
|
254
|
+
attrs = [
|
255
|
+
"email",
|
256
|
+
"course_title",
|
257
|
+
"activity_date",
|
258
|
+
"course_subject",
|
259
|
+
]
|
260
|
+
|
261
|
+
filtered_data = []
|
262
|
+
for engagement in ENGAGEMENTS:
|
263
|
+
for result in results:
|
264
|
+
if engagement["email"] == result["email"]:
|
265
|
+
data = {attr: engagement[attr] for attr in attrs}
|
266
|
+
data["learning_time_hours"] = round(engagement["learning_time_seconds"] / 3600, 1)
|
267
|
+
filtered_data.append(data)
|
268
|
+
break
|
269
|
+
|
270
|
+
received_data = sorted(results, key=lambda x: x["email"])
|
271
|
+
expected_data = sorted(filtered_data, key=lambda x: x["email"])
|
272
|
+
assert received_data == expected_data
|
273
|
+
|
274
|
+
@patch(
|
275
|
+
"enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_enrollments_data"
|
276
|
+
)
|
277
|
+
@patch(
|
278
|
+
"enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_engagements_data"
|
279
|
+
)
|
280
|
+
def test_get(self, mock_fetch_and_cache_engagements_data, mock_fetch_and_cache_enrollments_data):
|
281
|
+
"""
|
282
|
+
Test the GET method for the AdvanceAnalyticsEnrollmentStatsView works.
|
283
|
+
"""
|
284
|
+
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
285
|
+
mock_fetch_and_cache_engagements_data.return_value = engagements_dataframe()
|
286
|
+
|
287
|
+
response = self.client.get(self.url)
|
288
|
+
assert response.status_code == status.HTTP_200_OK
|
289
|
+
data = response.json()
|
290
|
+
assert data == {
|
291
|
+
'engagements_over_time': [
|
292
|
+
{'activity_date': '2021-07-19T00:00:00', 'enroll_type': 'certificate', 'sum': 0.0},
|
293
|
+
{'activity_date': '2021-07-26T00:00:00', 'enroll_type': 'certificate', 'sum': 4.4},
|
294
|
+
{'activity_date': '2021-07-27T00:00:00', 'enroll_type': 'certificate', 'sum': 1.2},
|
295
|
+
{'activity_date': '2021-08-05T00:00:00', 'enroll_type': 'certificate', 'sum': 3.6},
|
296
|
+
{'activity_date': '2021-08-21T00:00:00', 'enroll_type': 'certificate', 'sum': 2.7},
|
297
|
+
{'activity_date': '2021-09-02T00:00:00', 'enroll_type': 'certificate', 'sum': 1.3},
|
298
|
+
{'activity_date': '2021-09-21T00:00:00', 'enroll_type': 'certificate', 'sum': 1.5},
|
299
|
+
{'activity_date': '2022-05-17T00:00:00', 'enroll_type': 'certificate', 'sum': 0.0}
|
300
|
+
],
|
301
|
+
'top_courses_by_engagement': [
|
302
|
+
{
|
303
|
+
'course_key': 'Kcpr+XoR30',
|
304
|
+
'course_title': 'Assimilated even-keeled focus group',
|
305
|
+
'enroll_type': 'certificate',
|
306
|
+
'count': 0.0
|
307
|
+
},
|
308
|
+
{
|
309
|
+
'course_key': 'luGg+KNt30',
|
310
|
+
'course_title': 'Synergized reciprocal encoding',
|
311
|
+
'enroll_type': 'certificate',
|
312
|
+
'count': 14.786944444444444
|
313
|
+
}
|
314
|
+
],
|
315
|
+
'top_subjects_by_engagement': [
|
316
|
+
{
|
317
|
+
'course_subject': 'business-management',
|
318
|
+
'enroll_type': 'certificate',
|
319
|
+
'count': 14.786944444444444
|
320
|
+
},
|
321
|
+
{
|
322
|
+
'course_subject': 'engineering',
|
323
|
+
'enroll_type': 'certificate',
|
324
|
+
'count': 0.0
|
325
|
+
}
|
326
|
+
]
|
327
|
+
}
|
328
|
+
|
329
|
+
@patch("enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_enrollments_data")
|
330
|
+
@patch("enterprise_data.api.v1.views.analytics_engagements.fetch_and_cache_engagements_data")
|
331
|
+
@ddt.data(
|
332
|
+
EngagementChart.ENGAGEMENTS_OVER_TIME.value,
|
333
|
+
EngagementChart.TOP_COURSES_BY_ENGAGEMENTS.value,
|
334
|
+
EngagementChart.TOP_SUBJECTS_BY_ENGAGEMENTS.value,
|
335
|
+
)
|
336
|
+
def test_get_csv(self, chart_type, mock_fetch_and_cache_engagements_data, mock_fetch_and_cache_enrollments_data):
|
337
|
+
"""
|
338
|
+
Test that AdvanceAnalyticsEngagementStatsView return correct CSV data.
|
339
|
+
"""
|
340
|
+
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
341
|
+
mock_fetch_and_cache_engagements_data.return_value = engagements_dataframe()
|
342
|
+
response = self.client.get(self.url, {"response_type": ResponseType.CSV.value, "chart_type": chart_type})
|
343
|
+
assert response.status_code == status.HTTP_200_OK
|
344
|
+
assert response["Content-Type"] == "text/csv"
|
345
|
+
# verify the response content
|
346
|
+
assert response.content == ENGAGEMENT_STATS_CSVS[chart_type]
|
347
|
+
|
348
|
+
@ddt.data(
|
349
|
+
{
|
350
|
+
"params": {"start_date": 1},
|
351
|
+
"error": {
|
352
|
+
"start_date": [
|
353
|
+
"Date has wrong format. Use one of these formats instead: YYYY-MM-DD."
|
354
|
+
]
|
355
|
+
},
|
356
|
+
},
|
357
|
+
{
|
358
|
+
"params": {"end_date": 2},
|
359
|
+
"error": {
|
360
|
+
"end_date": [
|
361
|
+
"Date has wrong format. Use one of these formats instead: YYYY-MM-DD."
|
362
|
+
]
|
363
|
+
},
|
364
|
+
},
|
365
|
+
{
|
366
|
+
"params": {"start_date": "2024-01-01", "end_date": "2023-01-01"},
|
367
|
+
"error": {
|
368
|
+
"non_field_errors": [
|
369
|
+
"start_date should be less than or equal to end_date."
|
370
|
+
]
|
371
|
+
},
|
372
|
+
},
|
373
|
+
{
|
374
|
+
"params": {"calculation": "invalid"},
|
375
|
+
"error": {"calculation": [INVALID_CALCULATION_ERROR]},
|
376
|
+
},
|
377
|
+
{
|
378
|
+
"params": {"granularity": "invalid"},
|
379
|
+
"error": {"granularity": [INVALID_GRANULARITY_ERROR]},
|
380
|
+
},
|
381
|
+
{"params": {"chart_type": "invalid"}, "error": {"chart_type": [INVALID_CSV_ERROR1]}},
|
382
|
+
)
|
383
|
+
@ddt.unpack
|
384
|
+
def test_get_invalid_query_params(self, params, error):
|
385
|
+
"""
|
386
|
+
Test the GET method return correct error if any query param value is incorrect.
|
387
|
+
"""
|
388
|
+
response = self.client.get(self.url, params)
|
389
|
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
390
|
+
assert response.json() == error
|
File without changes
|
File without changes
|
File without changes
|