edx-enterprise-data 8.6.1__py3-none-any.whl → 8.8.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.6.1.dist-info → edx_enterprise_data-8.8.0.dist-info}/METADATA +1 -1
- {edx_enterprise_data-8.6.1.dist-info → edx_enterprise_data-8.8.0.dist-info}/RECORD +23 -17
- enterprise_data/__init__.py +1 -1
- enterprise_data/admin_analytics/completions_utils.py +261 -0
- enterprise_data/admin_analytics/constants.py +9 -3
- enterprise_data/admin_analytics/utils.py +50 -11
- enterprise_data/api/v1/paginators.py +1 -1
- enterprise_data/api/v1/serializers.py +44 -30
- enterprise_data/api/v1/urls.py +21 -4
- enterprise_data/api/v1/views/analytics_enrollments.py +42 -53
- enterprise_data/api/v1/views/analytics_leaderboard.py +120 -0
- enterprise_data/api/v1/views/enterprise_admin.py +9 -5
- enterprise_data/api/v1/views/enterprise_completions.py +177 -0
- enterprise_data/renderers.py +14 -0
- enterprise_data/tests/admin_analytics/mock_analytics_data.py +501 -0
- enterprise_data/tests/admin_analytics/mock_enrollments.py +23 -7
- enterprise_data/tests/admin_analytics/test_analytics_enrollments.py +23 -22
- enterprise_data/tests/admin_analytics/test_analytics_leaderboard.py +163 -0
- enterprise_data/tests/admin_analytics/test_enterprise_completions.py +202 -0
- enterprise_data/tests/api/v1/views/test_enterprise_admin.py +4 -0
- {edx_enterprise_data-8.6.1.dist-info → edx_enterprise_data-8.8.0.dist-info}/LICENSE +0 -0
- {edx_enterprise_data-8.6.1.dist-info → edx_enterprise_data-8.8.0.dist-info}/WHEEL +0 -0
- {edx_enterprise_data-8.6.1.dist-info → edx_enterprise_data-8.8.0.dist-info}/top_level.txt +0 -0
enterprise_data/api/v1/urls.py
CHANGED
@@ -8,12 +8,14 @@ from rest_framework.routers import DefaultRouter
|
|
8
8
|
from django.urls import re_path
|
9
9
|
|
10
10
|
from enterprise_data.api.v1.views import enterprise_admin as enterprise_admin_views
|
11
|
+
from enterprise_data.api.v1.views import enterprise_completions as enterprise_completions_views
|
11
12
|
from enterprise_data.api.v1.views import enterprise_learner as enterprise_learner_views
|
12
13
|
from enterprise_data.api.v1.views import enterprise_offers as enterprise_offers_views
|
13
14
|
from enterprise_data.api.v1.views.analytics_enrollments import (
|
14
15
|
AdvanceAnalyticsEnrollmentStatsView,
|
15
16
|
AdvanceAnalyticsIndividualEnrollmentsView,
|
16
17
|
)
|
18
|
+
from enterprise_data.api.v1.views.analytics_leaderboard import AdvanceAnalyticsLeaderboardView
|
17
19
|
from enterprise_data.constants import UUID4_REGEX
|
18
20
|
|
19
21
|
app_name = 'enterprise_data_api_v1'
|
@@ -52,25 +54,40 @@ urlpatterns = [
|
|
52
54
|
name='enterprise-admin-insights'
|
53
55
|
),
|
54
56
|
re_path(
|
55
|
-
fr'^admin/
|
57
|
+
fr'^admin/analytics/(?P<enterprise_id>{UUID4_REGEX})$',
|
56
58
|
enterprise_admin_views.EnterpriseAdminAnalyticsAggregatesView.as_view(),
|
57
59
|
name='enterprise-admin-analytics-aggregates'
|
58
60
|
),
|
59
61
|
re_path(
|
60
|
-
fr'^admin/
|
62
|
+
fr'^admin/analytics/(?P<enterprise_uuid>{UUID4_REGEX})/leaderboard$',
|
63
|
+
AdvanceAnalyticsLeaderboardView.as_view(),
|
64
|
+
name='enterprise-admin-analytics-leaderboard'
|
65
|
+
),
|
66
|
+
re_path(
|
67
|
+
fr'^admin/analytics/(?P<enterprise_uuid>{UUID4_REGEX})/enrollments/stats$',
|
61
68
|
AdvanceAnalyticsEnrollmentStatsView.as_view(),
|
62
69
|
name='enterprise-admin-analytics-enrollments-stats'
|
63
70
|
),
|
64
71
|
re_path(
|
65
|
-
fr'^admin/
|
72
|
+
fr'^admin/analytics/(?P<enterprise_uuid>{UUID4_REGEX})/enrollments$',
|
66
73
|
AdvanceAnalyticsIndividualEnrollmentsView.as_view(),
|
67
74
|
name='enterprise-admin-analytics-enrollments'
|
68
75
|
),
|
69
76
|
re_path(
|
70
|
-
fr'^admin/
|
77
|
+
fr'^admin/analytics/(?P<enterprise_id>{UUID4_REGEX})/skills/stats',
|
71
78
|
enterprise_admin_views.EnterpriseAdminAnalyticsSkillsView.as_view(),
|
72
79
|
name='enterprise-admin-analytics-skills'
|
73
80
|
),
|
81
|
+
re_path(
|
82
|
+
fr'^admin/analytics/(?P<enterprise_id>{UUID4_REGEX})/completions/stats$',
|
83
|
+
enterprise_completions_views.EnterrpiseAdminCompletionsStatsView.as_view(),
|
84
|
+
name='enterprise-admin-analytics-completions-stats'
|
85
|
+
),
|
86
|
+
re_path(
|
87
|
+
fr'^admin/analytics/(?P<enterprise_id>{UUID4_REGEX})/completions$',
|
88
|
+
enterprise_completions_views.EnterrpiseAdminCompletionsView.as_view(),
|
89
|
+
name='enterprise-admin-analytics-completions'
|
90
|
+
),
|
74
91
|
]
|
75
92
|
|
76
93
|
urlpatterns += router.urls
|
@@ -1,5 +1,5 @@
|
|
1
1
|
"""Advance Analytics for Enrollments"""
|
2
|
-
from datetime import datetime
|
2
|
+
from datetime import datetime
|
3
3
|
|
4
4
|
from edx_rbac.decorators import permission_required
|
5
5
|
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
@@ -8,37 +8,22 @@ from rest_framework.views import APIView
|
|
8
8
|
|
9
9
|
from django.http import HttpResponse, StreamingHttpResponse
|
10
10
|
|
11
|
-
from enterprise_data.admin_analytics.constants import
|
12
|
-
from enterprise_data.admin_analytics.data_loaders import fetch_max_enrollment_datetime
|
11
|
+
from enterprise_data.admin_analytics.constants import Calculation, EnrollmentChart, Granularity, ResponseType
|
13
12
|
from enterprise_data.admin_analytics.utils import (
|
14
13
|
calculation_aggregation,
|
15
14
|
fetch_and_cache_enrollments_data,
|
15
|
+
fetch_enrollments_cache_expiry_timestamp,
|
16
16
|
granularity_aggregation,
|
17
17
|
)
|
18
18
|
from enterprise_data.api.v1.paginators import AdvanceAnalyticsPagination
|
19
19
|
from enterprise_data.api.v1.serializers import (
|
20
|
-
AdvanceAnalyticsEnrollmentSerializer,
|
21
20
|
AdvanceAnalyticsEnrollmentStatsSerializer,
|
21
|
+
AdvanceAnalyticsQueryParamSerializer,
|
22
22
|
)
|
23
23
|
from enterprise_data.renderers import IndividualEnrollmentsCSVRenderer
|
24
24
|
from enterprise_data.utils import date_filter
|
25
25
|
|
26
26
|
|
27
|
-
def fetch_enrollments_cache_expiry_timestamp():
|
28
|
-
"""Calculate cache expiry timestamp"""
|
29
|
-
# TODO: Implement correct cache expiry logic for `enrollments` data.
|
30
|
-
# Current cache expiry logic is based on `enterprise_learner_enrollment` table,
|
31
|
-
# Which has nothing to do with the `enrollments` data. Instead cache expiry should
|
32
|
-
# be based on `fact_enrollment_admin_dash` table. Currently we have no timestamp in
|
33
|
-
# `fact_enrollment_admin_dash` table that can be used for cache expiry. Add a new
|
34
|
-
# column in the table for this purpose and then use that column for cache expiry.
|
35
|
-
last_updated_at = fetch_max_enrollment_datetime()
|
36
|
-
cache_expiry = (
|
37
|
-
last_updated_at + timedelta(days=1) if last_updated_at else datetime.now()
|
38
|
-
)
|
39
|
-
return cache_expiry
|
40
|
-
|
41
|
-
|
42
27
|
class AdvanceAnalyticsIndividualEnrollmentsView(APIView):
|
43
28
|
"""
|
44
29
|
API for getting the advance analytics individual enrollments data.
|
@@ -50,7 +35,7 @@ class AdvanceAnalyticsIndividualEnrollmentsView(APIView):
|
|
50
35
|
@permission_required('can_access_enterprise', fn=lambda request, enterprise_uuid: enterprise_uuid)
|
51
36
|
def get(self, request, enterprise_uuid):
|
52
37
|
"""Get individual enrollments data"""
|
53
|
-
serializer =
|
38
|
+
serializer = AdvanceAnalyticsQueryParamSerializer(data=request.GET)
|
54
39
|
serializer.is_valid(raise_exception=True)
|
55
40
|
|
56
41
|
cache_expiry = fetch_enrollments_cache_expiry_timestamp()
|
@@ -59,7 +44,7 @@ class AdvanceAnalyticsIndividualEnrollmentsView(APIView):
|
|
59
44
|
# get values from query params or use default values
|
60
45
|
start_date = serializer.data.get('start_date', enrollments_df.enterprise_enrollment_date.min())
|
61
46
|
end_date = serializer.data.get('end_date', datetime.now())
|
62
|
-
|
47
|
+
response_type = request.query_params.get('response_type', ResponseType.JSON.value)
|
63
48
|
|
64
49
|
# filter enrollments by date
|
65
50
|
enrollments = date_filter(start_date, end_date, enrollments_df, "enterprise_enrollment_date")
|
@@ -77,11 +62,12 @@ class AdvanceAnalyticsIndividualEnrollmentsView(APIView):
|
|
77
62
|
enrollments["enterprise_enrollment_date"] = enrollments["enterprise_enrollment_date"].dt.date
|
78
63
|
enrollments = enrollments.sort_values(by="enterprise_enrollment_date", ascending=False).reset_index(drop=True)
|
79
64
|
|
80
|
-
if
|
65
|
+
if response_type == ResponseType.CSV.value:
|
66
|
+
filename = f"""individual_enrollments, {start_date} - {end_date}.csv"""
|
81
67
|
return StreamingHttpResponse(
|
82
68
|
IndividualEnrollmentsCSVRenderer().render(self._stream_serialized_data(enrollments)),
|
83
69
|
content_type="text/csv",
|
84
|
-
headers={"Content-Disposition": 'attachment; filename="
|
70
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
85
71
|
)
|
86
72
|
|
87
73
|
paginator = self.pagination_class()
|
@@ -121,11 +107,12 @@ class AdvanceAnalyticsEnrollmentStatsView(APIView):
|
|
121
107
|
# get values from query params or use default
|
122
108
|
start_date = serializer.data.get('start_date', enrollments_df.enterprise_enrollment_date.min())
|
123
109
|
end_date = serializer.data.get('end_date', datetime.now())
|
124
|
-
granularity = serializer.data.get('granularity',
|
125
|
-
calculation = serializer.data.get('calculation',
|
126
|
-
|
110
|
+
granularity = serializer.data.get('granularity', Granularity.DAILY.value)
|
111
|
+
calculation = serializer.data.get('calculation', Calculation.TOTAL.value)
|
112
|
+
response_type = serializer.data.get('response_type', ResponseType.JSON.value)
|
113
|
+
chart_type = serializer.data.get('chart_type')
|
127
114
|
|
128
|
-
if
|
115
|
+
if response_type == ResponseType.JSON.value:
|
129
116
|
data = {
|
130
117
|
"enrollments_over_time": self.construct_enrollments_over_time(
|
131
118
|
enrollments_df.copy(),
|
@@ -146,26 +133,28 @@ class AdvanceAnalyticsEnrollmentStatsView(APIView):
|
|
146
133
|
),
|
147
134
|
}
|
148
135
|
return Response(data)
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
136
|
+
|
137
|
+
if response_type == ResponseType.CSV.value:
|
138
|
+
if chart_type == EnrollmentChart.ENROLLMENTS_OVER_TIME.value:
|
139
|
+
return self.construct_enrollments_over_time_csv(
|
140
|
+
enrollments_df.copy(),
|
141
|
+
start_date,
|
142
|
+
end_date,
|
143
|
+
granularity,
|
144
|
+
calculation,
|
145
|
+
)
|
146
|
+
elif chart_type == EnrollmentChart.TOP_COURSES_BY_ENROLLMENTS.value:
|
147
|
+
return self.construct_top_courses_by_enrollments_csv(
|
148
|
+
enrollments_df.copy(),
|
149
|
+
start_date,
|
150
|
+
end_date,
|
151
|
+
)
|
152
|
+
elif chart_type == EnrollmentChart.TOP_SUBJECTS_BY_ENROLLMENTS.value:
|
153
|
+
return self.construct_top_subjects_by_enrollments_csv(
|
154
|
+
enrollments_df.copy(),
|
155
|
+
start_date,
|
156
|
+
end_date,
|
157
|
+
)
|
169
158
|
|
170
159
|
def enrollments_over_time_common(self, enrollments_df, start_date, end_date, granularity, calculation):
|
171
160
|
"""
|
@@ -175,8 +164,8 @@ class AdvanceAnalyticsEnrollmentStatsView(APIView):
|
|
175
164
|
enrollments_df {DataFrame} -- DataFrame of enrollments
|
176
165
|
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
177
166
|
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
178
|
-
granularity {str} -- Granularity of the data. One of
|
179
|
-
calculation {str} -- Calculation of the data. One of
|
167
|
+
granularity {str} -- Granularity of the data. One of Granularity choices
|
168
|
+
calculation {str} -- Calculation of the data. One of Calculation choices
|
180
169
|
"""
|
181
170
|
# filter enrollments by date
|
182
171
|
enrollments = date_filter(start_date, end_date, enrollments_df, "enterprise_enrollment_date")
|
@@ -202,8 +191,8 @@ class AdvanceAnalyticsEnrollmentStatsView(APIView):
|
|
202
191
|
enrollments_df {DataFrame} -- DataFrame of enrollments
|
203
192
|
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
204
193
|
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
205
|
-
granularity {str} -- Granularity of the data. One of
|
206
|
-
calculation {str} -- Calculation of the data. One of
|
194
|
+
granularity {str} -- Granularity of the data. One of Granularity choices
|
195
|
+
calculation {str} -- Calculation of the data. One of Calculation choices
|
207
196
|
"""
|
208
197
|
enrollments = self.enrollments_over_time_common(enrollments_df, start_date, end_date, granularity, calculation)
|
209
198
|
|
@@ -218,8 +207,8 @@ class AdvanceAnalyticsEnrollmentStatsView(APIView):
|
|
218
207
|
enrollments_df {DataFrame} -- DataFrame of enrollments
|
219
208
|
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
220
209
|
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
221
|
-
granularity {str} -- Granularity of the data. One of
|
222
|
-
calculation {str} -- Calculation of the data. One of
|
210
|
+
granularity {str} -- Granularity of the data. One of Granularity choices
|
211
|
+
calculation {str} -- Calculation of the data. One of Calculation choices
|
223
212
|
"""
|
224
213
|
enrollments = self.enrollments_over_time_common(enrollments_df, start_date, end_date, granularity, calculation)
|
225
214
|
|
@@ -0,0 +1,120 @@
|
|
1
|
+
"""Advance Analytics for Leaderboard"""
|
2
|
+
from datetime import datetime
|
3
|
+
|
4
|
+
import numpy as np
|
5
|
+
import pandas as pd
|
6
|
+
from edx_rbac.decorators import permission_required
|
7
|
+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
8
|
+
from rest_framework.views import APIView
|
9
|
+
|
10
|
+
from django.http import StreamingHttpResponse
|
11
|
+
|
12
|
+
from enterprise_data.admin_analytics.constants import ResponseType
|
13
|
+
from enterprise_data.admin_analytics.utils import (
|
14
|
+
fetch_and_cache_engagements_data,
|
15
|
+
fetch_and_cache_enrollments_data,
|
16
|
+
fetch_engagements_cache_expiry_timestamp,
|
17
|
+
fetch_enrollments_cache_expiry_timestamp,
|
18
|
+
)
|
19
|
+
from enterprise_data.api.v1.paginators import AdvanceAnalyticsPagination
|
20
|
+
from enterprise_data.api.v1.serializers import AdvanceAnalyticsQueryParamSerializer
|
21
|
+
from enterprise_data.renderers import LeaderboardCSVRenderer
|
22
|
+
from enterprise_data.utils import date_filter
|
23
|
+
|
24
|
+
|
25
|
+
class AdvanceAnalyticsLeaderboardView(APIView):
|
26
|
+
"""
|
27
|
+
API for getting the advance analytics leaderboard data.
|
28
|
+
"""
|
29
|
+
authentication_classes = (JwtAuthentication,)
|
30
|
+
pagination_class = AdvanceAnalyticsPagination
|
31
|
+
http_method_names = ['get']
|
32
|
+
|
33
|
+
@permission_required('can_access_enterprise', fn=lambda request, enterprise_uuid: enterprise_uuid)
|
34
|
+
def get(self, request, enterprise_uuid):
|
35
|
+
"""Get leaderboard data"""
|
36
|
+
serializer = AdvanceAnalyticsQueryParamSerializer(data=request.GET)
|
37
|
+
serializer.is_valid(raise_exception=True)
|
38
|
+
|
39
|
+
enrollments_cache_expiry = fetch_enrollments_cache_expiry_timestamp()
|
40
|
+
enrollments_df = fetch_and_cache_enrollments_data(enterprise_uuid, enrollments_cache_expiry)
|
41
|
+
|
42
|
+
engagements_cache_expiry = fetch_engagements_cache_expiry_timestamp()
|
43
|
+
engagements_df = fetch_and_cache_engagements_data(enterprise_uuid, engagements_cache_expiry)
|
44
|
+
|
45
|
+
start_date = serializer.data.get('start_date', enrollments_df.enterprise_enrollment_date.min())
|
46
|
+
end_date = serializer.data.get('end_date', datetime.now())
|
47
|
+
response_type = serializer.data.get('response_type', ResponseType.JSON.value)
|
48
|
+
|
49
|
+
# only include learners who have passed the course
|
50
|
+
enrollments_df = enrollments_df[enrollments_df["has_passed"] == 1]
|
51
|
+
|
52
|
+
# filter enrollments by date
|
53
|
+
enrollments_df = date_filter(start_date, end_date, enrollments_df, "passed_date")
|
54
|
+
|
55
|
+
completions = enrollments_df.groupby(["email"]).size().reset_index()
|
56
|
+
completions.columns = ["email", "course_completions"]
|
57
|
+
|
58
|
+
# filter engagements by date
|
59
|
+
engagements_df = date_filter(start_date, end_date, engagements_df, "activity_date")
|
60
|
+
|
61
|
+
engage = (
|
62
|
+
engagements_df.groupby(["email"])
|
63
|
+
.agg({"is_engaged": ["sum"], "learning_time_seconds": ["sum"]})
|
64
|
+
.reset_index()
|
65
|
+
)
|
66
|
+
engage.columns = ["email", "daily_sessions", "learning_time_seconds"]
|
67
|
+
engage["learning_time_hours"] = round(
|
68
|
+
engage["learning_time_seconds"].astype("float") / 60 / 60, 1
|
69
|
+
)
|
70
|
+
engage["average_session_length"] = round(
|
71
|
+
engage["learning_time_hours"] / engage["daily_sessions"].astype("float"), 1
|
72
|
+
)
|
73
|
+
|
74
|
+
leaderboard_df = engage.merge(completions, on="email", how="left")
|
75
|
+
leaderboard_df = leaderboard_df.sort_values(
|
76
|
+
by=["learning_time_hours", "daily_sessions", "course_completions"],
|
77
|
+
ascending=[False, False, False],
|
78
|
+
)
|
79
|
+
|
80
|
+
# move the aggregated row with email 'null' to the end of the table
|
81
|
+
idx = leaderboard_df.index[leaderboard_df['email'] == 'null']
|
82
|
+
leaderboard_df.loc[idx, 'email'] = 'learners who have not shared consent'
|
83
|
+
leaderboard_df = pd.concat([leaderboard_df.drop(idx), leaderboard_df.loc[idx]])
|
84
|
+
|
85
|
+
# convert `nan` values to `None` because `nan` is not JSON serializable
|
86
|
+
leaderboard_df = leaderboard_df.replace(np.nan, None)
|
87
|
+
|
88
|
+
if response_type == ResponseType.CSV.value:
|
89
|
+
filename = f"""Leaderboard, {start_date} - {end_date}.csv"""
|
90
|
+
leaderboard_df = leaderboard_df[
|
91
|
+
[
|
92
|
+
"email",
|
93
|
+
"learning_time_hours",
|
94
|
+
"daily_sessions",
|
95
|
+
"average_session_length",
|
96
|
+
"course_completions",
|
97
|
+
]
|
98
|
+
]
|
99
|
+
return StreamingHttpResponse(
|
100
|
+
LeaderboardCSVRenderer().render(self._stream_serialized_data(leaderboard_df)),
|
101
|
+
content_type="text/csv",
|
102
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
103
|
+
)
|
104
|
+
|
105
|
+
paginator = self.pagination_class()
|
106
|
+
page = paginator.paginate_queryset(leaderboard_df, request)
|
107
|
+
serialized_data = page.data.to_dict(orient='records')
|
108
|
+
response = paginator.get_paginated_response(serialized_data)
|
109
|
+
|
110
|
+
return response
|
111
|
+
|
112
|
+
def _stream_serialized_data(self, leaderboard_df, chunk_size=50000):
|
113
|
+
"""
|
114
|
+
Stream the serialized data.
|
115
|
+
"""
|
116
|
+
total_rows = leaderboard_df.shape[0]
|
117
|
+
for start_index in range(0, total_rows, chunk_size):
|
118
|
+
end_index = min(start_index + chunk_size, total_rows)
|
119
|
+
chunk = leaderboard_df.iloc[start_index:end_index]
|
120
|
+
yield from chunk.to_dict(orient='records')
|
@@ -189,17 +189,21 @@ class EnterpriseAdminAnalyticsSkillsView(APIView):
|
|
189
189
|
data=request.GET
|
190
190
|
)
|
191
191
|
serializer.is_valid(raise_exception=True)
|
192
|
-
|
193
|
-
start_date = serializer.data.get("start_date")
|
194
|
-
end_date = serializer.data.get("end_date", datetime.now())
|
195
|
-
|
196
192
|
last_updated_at = fetch_max_enrollment_datetime()
|
197
193
|
cache_expiry = (
|
198
194
|
last_updated_at + timedelta(days=1) if last_updated_at else datetime.now()
|
199
195
|
)
|
196
|
+
|
197
|
+
enrollment = fetch_and_cache_enrollments_data(
|
198
|
+
enterprise_id, cache_expiry
|
199
|
+
).copy()
|
200
|
+
|
201
|
+
start_date = serializer.data.get('start_date', enrollment.enterprise_enrollment_date.min())
|
202
|
+
end_date = serializer.data.get('end_date', datetime.now())
|
203
|
+
|
200
204
|
skills = fetch_and_cache_skills_data(enterprise_id, cache_expiry).copy()
|
201
205
|
|
202
|
-
if
|
206
|
+
if serializer.data.get('response_type') == 'csv':
|
203
207
|
csv_data = get_top_skills_csv_data(skills, start_date, end_date)
|
204
208
|
response = HttpResponse(content_type='text/csv')
|
205
209
|
filename = f"Skills by Enrollment and Completion, {start_date} - {end_date}.csv"
|
@@ -0,0 +1,177 @@
|
|
1
|
+
"""Views for enterprise admin completions analytics."""
|
2
|
+
import datetime
|
3
|
+
from datetime import datetime, timedelta
|
4
|
+
|
5
|
+
from edx_rbac.decorators import permission_required
|
6
|
+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
7
|
+
from rest_framework.response import Response
|
8
|
+
from rest_framework.status import HTTP_200_OK
|
9
|
+
from rest_framework.views import APIView
|
10
|
+
|
11
|
+
from django.http import HttpResponse
|
12
|
+
|
13
|
+
from enterprise_data.admin_analytics.completions_utils import (
|
14
|
+
get_completions_over_time,
|
15
|
+
get_csv_data_for_completions_over_time,
|
16
|
+
get_csv_data_for_top_courses_by_completions,
|
17
|
+
get_csv_data_for_top_subjects_by_completions,
|
18
|
+
get_top_courses_by_completions,
|
19
|
+
get_top_subjects_by_completions,
|
20
|
+
)
|
21
|
+
from enterprise_data.admin_analytics.constants import Calculation, Granularity
|
22
|
+
from enterprise_data.admin_analytics.data_loaders import fetch_max_enrollment_datetime
|
23
|
+
from enterprise_data.admin_analytics.utils import ChartType, fetch_and_cache_enrollments_data
|
24
|
+
from enterprise_data.api.v1 import serializers
|
25
|
+
from enterprise_data.api.v1.paginators import AdvanceAnalyticsPagination
|
26
|
+
from enterprise_data.utils import date_filter
|
27
|
+
|
28
|
+
|
29
|
+
class EnterrpiseAdminCompletionsStatsView(APIView):
|
30
|
+
"""
|
31
|
+
API for getting the enterprise admin completions.
|
32
|
+
"""
|
33
|
+
authentication_classes = (JwtAuthentication,)
|
34
|
+
http_method_names = ['get']
|
35
|
+
|
36
|
+
@permission_required(
|
37
|
+
"can_access_enterprise", fn=lambda request, enterprise_id: enterprise_id
|
38
|
+
)
|
39
|
+
def get(self, request, enterprise_id):
|
40
|
+
"""
|
41
|
+
HTTP GET endpoint to retrieve the enterprise admin completions
|
42
|
+
"""
|
43
|
+
serializer = serializers.AdminAnalyticsAggregatesQueryParamsSerializer(
|
44
|
+
data=request.GET
|
45
|
+
)
|
46
|
+
serializer.is_valid(raise_exception=True)
|
47
|
+
|
48
|
+
last_updated_at = fetch_max_enrollment_datetime()
|
49
|
+
cache_expiry = (
|
50
|
+
last_updated_at + timedelta(days=1) if last_updated_at else datetime.now()
|
51
|
+
)
|
52
|
+
|
53
|
+
enrollments = fetch_and_cache_enrollments_data(
|
54
|
+
enterprise_id, cache_expiry
|
55
|
+
).copy()
|
56
|
+
# Use start and end date if provided by the client, if client has not provided then use
|
57
|
+
# 1. minimum enrollment date from the data as the start_date
|
58
|
+
# 2. today's date as the end_date
|
59
|
+
start_date = serializer.data.get(
|
60
|
+
"start_date", enrollments.enterprise_enrollment_date.min()
|
61
|
+
)
|
62
|
+
end_date = serializer.data.get("end_date", datetime.now())
|
63
|
+
|
64
|
+
if serializer.data.get('response_type') == 'csv':
|
65
|
+
chart_type = serializer.data.get('chart_type')
|
66
|
+
response = HttpResponse(content_type='text/csv')
|
67
|
+
csv_data = {}
|
68
|
+
|
69
|
+
if chart_type == ChartType.COMPLETIONS_OVER_TIME.value:
|
70
|
+
csv_data = get_csv_data_for_completions_over_time(
|
71
|
+
start_date=start_date,
|
72
|
+
end_date=end_date,
|
73
|
+
enrollments=enrollments.copy(),
|
74
|
+
date_agg=serializer.data.get('granularity', Granularity.DAILY.value),
|
75
|
+
calc=serializer.data.get('calculation', Calculation.TOTAL.value),
|
76
|
+
)
|
77
|
+
elif chart_type == ChartType.TOP_COURSES_BY_COMPLETIONS.value:
|
78
|
+
csv_data = get_csv_data_for_top_courses_by_completions(
|
79
|
+
start_date=start_date, end_date=end_date, enrollments=enrollments.copy()
|
80
|
+
)
|
81
|
+
elif chart_type == ChartType.TOP_SUBJECTS_BY_COMPLETIONS.value:
|
82
|
+
csv_data = get_csv_data_for_top_subjects_by_completions(
|
83
|
+
start_date=start_date, end_date=end_date, enrollments=enrollments.copy()
|
84
|
+
)
|
85
|
+
filename = csv_data['filename']
|
86
|
+
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
87
|
+
csv_data['data'].to_csv(path_or_buf=response)
|
88
|
+
return response
|
89
|
+
|
90
|
+
completions_over_time = get_completions_over_time(
|
91
|
+
start_date=start_date,
|
92
|
+
end_date=end_date,
|
93
|
+
dff=enrollments.copy(),
|
94
|
+
date_agg=serializer.data.get('granularity', Granularity.DAILY.value),
|
95
|
+
calc=serializer.data.get('calculation', Calculation.TOTAL.value),
|
96
|
+
)
|
97
|
+
top_courses_by_completions = get_top_courses_by_completions(
|
98
|
+
start_date=start_date, end_date=end_date, dff=enrollments.copy()
|
99
|
+
)
|
100
|
+
top_subjects_by_completions = get_top_subjects_by_completions(
|
101
|
+
start_date=start_date, end_date=end_date, dff=enrollments.copy()
|
102
|
+
)
|
103
|
+
|
104
|
+
return Response(
|
105
|
+
data={
|
106
|
+
'completions_over_time': completions_over_time.to_dict(
|
107
|
+
orient="records"
|
108
|
+
),
|
109
|
+
'top_courses_by_completions': top_courses_by_completions.to_dict(
|
110
|
+
orient="records"
|
111
|
+
),
|
112
|
+
'top_subjects_by_completions': top_subjects_by_completions.to_dict(
|
113
|
+
orient="records"
|
114
|
+
),
|
115
|
+
},
|
116
|
+
status=HTTP_200_OK,
|
117
|
+
)
|
118
|
+
|
119
|
+
|
120
|
+
class EnterrpiseAdminCompletionsView(APIView):
|
121
|
+
"""
|
122
|
+
API for getting the enterprise admin completions.
|
123
|
+
"""
|
124
|
+
authentication_classes = (JwtAuthentication,)
|
125
|
+
http_method_names = ['get']
|
126
|
+
pagination_class = AdvanceAnalyticsPagination
|
127
|
+
|
128
|
+
@permission_required(
|
129
|
+
"can_access_enterprise", fn=lambda request, enterprise_id: enterprise_id
|
130
|
+
)
|
131
|
+
def get(self, request, enterprise_id):
|
132
|
+
"""
|
133
|
+
HTTP GET endpoint to retrieve the enterprise admin completions
|
134
|
+
"""
|
135
|
+
serializer = serializers.AdminAnalyticsAggregatesQueryParamsSerializer(
|
136
|
+
data=request.GET
|
137
|
+
)
|
138
|
+
serializer.is_valid(raise_exception=True)
|
139
|
+
|
140
|
+
last_updated_at = fetch_max_enrollment_datetime()
|
141
|
+
cache_expiry = (
|
142
|
+
last_updated_at + timedelta(days=1) if last_updated_at else datetime.now()
|
143
|
+
)
|
144
|
+
|
145
|
+
enrollments = fetch_and_cache_enrollments_data(
|
146
|
+
enterprise_id, cache_expiry
|
147
|
+
).copy()
|
148
|
+
# Use start and end date if provided by the client, if client has not provided then use
|
149
|
+
# 1. minimum enrollment date from the data as the start_date
|
150
|
+
# 2. today's date as the end_date
|
151
|
+
start_date = serializer.data.get(
|
152
|
+
'start_date', enrollments.enterprise_enrollment_date.min()
|
153
|
+
)
|
154
|
+
end_date = serializer.data.get('end_date', datetime.now())
|
155
|
+
|
156
|
+
dff = enrollments[enrollments['has_passed'] == 1]
|
157
|
+
|
158
|
+
# Date filtering
|
159
|
+
dff = date_filter(start=start_date, end=end_date, data_frame=dff, date_column='passed_date')
|
160
|
+
|
161
|
+
dff = dff[['email', 'course_title', 'course_subject', 'passed_date']]
|
162
|
+
dff['passed_date'] = dff['passed_date'].dt.date
|
163
|
+
dff = dff.sort_values(by="passed_date", ascending=False).reset_index(drop=True)
|
164
|
+
|
165
|
+
if serializer.data.get('response_type') == 'csv':
|
166
|
+
response = HttpResponse(content_type='text/csv')
|
167
|
+
filename = f"Individual Completions, {start_date} - {end_date}.csv"
|
168
|
+
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
169
|
+
dff.to_csv(path_or_buf=response, index=False)
|
170
|
+
return response
|
171
|
+
|
172
|
+
paginator = self.pagination_class()
|
173
|
+
page = paginator.paginate_queryset(dff, request)
|
174
|
+
serialized_data = page.data.to_dict(orient='records')
|
175
|
+
response = paginator.get_paginated_response(serialized_data)
|
176
|
+
|
177
|
+
return response
|
enterprise_data/renderers.py
CHANGED
@@ -43,3 +43,17 @@ class IndividualEnrollmentsCSVRenderer(CSVStreamingRenderer):
|
|
43
43
|
'enroll_type',
|
44
44
|
'enterprise_enrollment_date',
|
45
45
|
]
|
46
|
+
|
47
|
+
|
48
|
+
class LeaderboardCSVRenderer(CSVStreamingRenderer):
|
49
|
+
"""
|
50
|
+
Custom streaming csv renderer for advance analytics leaderboard data.
|
51
|
+
"""
|
52
|
+
|
53
|
+
header = [
|
54
|
+
'email',
|
55
|
+
'learning_time_hours',
|
56
|
+
'daily_sessions',
|
57
|
+
'average_session_length',
|
58
|
+
'course_completions',
|
59
|
+
]
|