edx-enterprise-data 8.8.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: edx-enterprise-data
3
- Version: 8.8.2
3
+ Version: 8.10.0
4
4
  Summary: Enterprise Reporting
5
5
  Home-page: https://github.com/openedx/edx-enterprise-data
6
6
  Author: edX
@@ -1,18 +1,18 @@
1
- enterprise_data/__init__.py,sha256=-VIr7vbXyC6NZ93tVq80ymKwQlt-e7RNEMF68U0vAV0,123
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=9gIzavWspZTk4vDfVKXJtdn0tSZ2xNgkF-Akf7AWIDM,2389
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=aHDgTHdsjbKNpgtNLDsl4giqhhrRkCGi72ysGIEk0Ao,817
15
- enterprise_data/admin_analytics/data_loaders.py,sha256=YpSyygATVFtItcWlkIHvjsX5Lh1qMw6onDK-ZHP_AUw,5586
14
+ enterprise_data/admin_analytics/constants.py,sha256=7WturLuMISekgcHHlgj45PPdPrDTYM-l21lA8-Q_Tfc,1107
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
18
18
  enterprise_data/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -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=9F2LGa8IKvglgeYNHw3Q0eEZUWknwHZMNZOdpDviEo4,12327
27
- enterprise_data/api/v1/urls.py,sha256=xFsBf3TTsdblFAiHq1Bj3h82Ye1PS3cgqLC0pIso2js,3504
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/analytics_enrollments.py,sha256=om8i5HTDxDMSny1fy49Wh38LdYLLz-k-oFQJqlQLOxg,16812
30
- enterprise_data/api/v1/views/analytics_leaderboard.py,sha256=EE34fJWYp8OGbIj8oRLlgMhbSm37w98oPXn1aPIZWWE,5860
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=F34FbOz1gLwHPUAQOg3mOjZoKirbCRrjZS2xFJSUBpw,9274
33
- enterprise_data/api/v1/views/enterprise_completions.py,sha256=OkH6eYNql6UGVU-Xjx7PzwzaHeUHfa6e4WJmkUoHGDM,8221
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=mLGTIP89VAQgqKtYlYnKUAKyZATzKnBtI_5aJwMmr7Q,18344
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.8.2.dist-info/LICENSE,sha256=dql8h4yceoMhuzlcK0TT_i-NgTFNIZsgE47Q4t3dUYI,34520
163
- edx_enterprise_data-8.8.2.dist-info/METADATA,sha256=xBoBTQy1LaX3WMjZLX5t8d-OVlkbxRQgyF9f0nLbrro,1569
164
- edx_enterprise_data-8.8.2.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
165
- edx_enterprise_data-8.8.2.dist-info/top_level.txt,sha256=f5F2kU-dob6MqiHJpgZkFzoCD5VMhsdpkTV5n9Tvq3I,59
166
- edx_enterprise_data-8.8.2.dist-info/RECORD,,
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,,
@@ -2,4 +2,4 @@
2
2
  Enterprise data api application. This Django app exposes API endpoints used by enterprises.
3
3
  """
4
4
 
5
- __version__ = "8.8.2"
5
+ __version__ = "8.10.0"
@@ -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'
@@ -1,6 +1,8 @@
1
1
  """
2
2
  Utility functions for fetching data from the database.
3
3
  """
4
+ from logging import getLogger
5
+
4
6
  import numpy
5
7
  import pandas
6
8
 
@@ -9,6 +11,8 @@ from django.http import Http404
9
11
  from enterprise_data.admin_analytics.database import run_query
10
12
  from enterprise_data.utils import timer
11
13
 
14
+ LOGGER = getLogger(__name__)
15
+
12
16
 
13
17
  def get_select_query(table: str, columns: list, enterprise_uuid: str) -> str:
14
18
  """
@@ -68,10 +72,13 @@ def fetch_enrollment_data(enterprise_uuid: str):
68
72
 
69
73
  with timer('fetch_enrollment_data'):
70
74
  results = run_query(query=query)
75
+
71
76
  if not results:
72
77
  raise Http404(f'No enrollment data found for enterprise {enterprise_uuid}')
73
78
 
79
+ LOGGER.info(f'[PLOTLY] Enrollment data fetched successfully. Records: {len(results)}')
74
80
  enrollments = pandas.DataFrame(numpy.array(results), columns=columns)
81
+ LOGGER.info('[PLOTLY] Enrollment data converted to DataFrame.')
75
82
 
76
83
  # Convert date columns to datetime.
77
84
  enrollments['enterprise_enrollment_date'] = enrollments['enterprise_enrollment_date'].astype('datetime64[ns]')
@@ -120,7 +127,9 @@ def fetch_engagement_data(enterprise_uuid: str):
120
127
  if not results:
121
128
  raise Http404(f'No engagement data found for enterprise {enterprise_uuid}')
122
129
 
130
+ LOGGER.info(f'[PLOTLY] Engagement data fetched successfully. Records: {len(results)}')
123
131
  engagement = pandas.DataFrame(numpy.array(results), columns=columns)
132
+ LOGGER.info('[PLOTLY] Engagement data converted to DataFrame.')
124
133
  engagement['activity_date'] = engagement['activity_date'].astype('datetime64[ns]')
125
134
 
126
135
  return engagement
@@ -180,7 +189,9 @@ def fetch_skills_data(enterprise_uuid: str):
180
189
  if not skills:
181
190
  raise Http404(f'No skills data found for enterprise {enterprise_uuid}')
182
191
 
192
+ LOGGER.info(f'[PLOTLY] Skills data fetched successfully. Records: {len(skills)}')
183
193
  skills = pandas.DataFrame(numpy.array(skills), columns=cols)
194
+ LOGGER.info('[PLOTLY] Skills data converted to DataFrame.')
184
195
  skills['date'] = skills['date'].astype('datetime64[ns]')
185
196
 
186
197
  return skills
@@ -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 Calculation, EnrollmentChart, Granularity, ResponseType
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
@@ -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={"Content-Disposition": f'attachment; filename="{filename}"'},
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
 
@@ -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