edx-enterprise-data 8.11.1__py3-none-any.whl → 8.12.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {edx_enterprise_data-8.11.1.dist-info → edx_enterprise_data-8.12.1.dist-info}/METADATA +1 -1
- {edx_enterprise_data-8.11.1.dist-info → edx_enterprise_data-8.12.1.dist-info}/RECORD +15 -15
- enterprise_data/__init__.py +1 -1
- enterprise_data/admin_analytics/database/queries/fact_enrollment_admin_dash.py +76 -0
- enterprise_data/admin_analytics/database/tables/fact_enrollment_admin_dash.py +116 -0
- enterprise_data/admin_analytics/database/utils.py +8 -3
- enterprise_data/api/v1/serializers.py +2 -0
- enterprise_data/api/v1/urls.py +3 -6
- enterprise_data/api/v1/views/analytics_enrollments.py +95 -331
- enterprise_data/api/v1/views/base.py +79 -0
- enterprise_data/tests/admin_analytics/mock_analytics_data.py +0 -12
- enterprise_data/tests/admin_analytics/test_analytics_enrollments.py +72 -115
- {edx_enterprise_data-8.11.1.dist-info → edx_enterprise_data-8.12.1.dist-info}/LICENSE +0 -0
- {edx_enterprise_data-8.11.1.dist-info → edx_enterprise_data-8.12.1.dist-info}/WHEEL +0 -0
- {edx_enterprise_data-8.11.1.dist-info → edx_enterprise_data-8.12.1.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
|
|
1
|
-
enterprise_data/__init__.py,sha256=
|
1
|
+
enterprise_data/__init__.py,sha256=8TjCXGqGwt4y6eG7nZC6OYCp9Dut66Hr_TTySgVt3PE,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
|
@@ -15,14 +15,14 @@ enterprise_data/admin_analytics/constants.py,sha256=7WturLuMISekgcHHlgj45PPdPrDT
|
|
15
15
|
enterprise_data/admin_analytics/data_loaders.py,sha256=4PSsIveNBtpFqwrLUjTl5oywHVYFAMdZrNRHRGMk4XY,6116
|
16
16
|
enterprise_data/admin_analytics/utils.py,sha256=CQuTlg36AALJiopp4us-JN8oTXsw-jDXSJenbphLDME,12270
|
17
17
|
enterprise_data/admin_analytics/database/__init__.py,sha256=vNSWKf2VV5xMegN7htJJtxtQEb0ASLC6frE2w0ZpYpE,104
|
18
|
-
enterprise_data/admin_analytics/database/utils.py,sha256=
|
18
|
+
enterprise_data/admin_analytics/database/utils.py,sha256=5u-d6ZQW95mF_r4bH8Xdi7DgpYAuDFOG_q0P-bjKXHU,1712
|
19
19
|
enterprise_data/admin_analytics/database/queries/__init__.py,sha256=IC5TLOr_GnydbrVbl2mWhwO3aUbYeHuDmfPTLmwGhZA,218
|
20
20
|
enterprise_data/admin_analytics/database/queries/fact_engagement_admin_dash.py,sha256=fq01Ni_sKnvSRoiPQfTnJ8TtRePQe5MBLmI5CpVy36o,747
|
21
|
-
enterprise_data/admin_analytics/database/queries/fact_enrollment_admin_dash.py,sha256=
|
21
|
+
enterprise_data/admin_analytics/database/queries/fact_enrollment_admin_dash.py,sha256=uuhX3OIB5Cp0-5uxN604HNEUuzb3s9nH3VR4CiEXs18,5388
|
22
22
|
enterprise_data/admin_analytics/database/tables/__init__.py,sha256=3ECKlKlz1wuTER5M57CMFvmLFp0oCnVQXAUeTN96Bs0,213
|
23
23
|
enterprise_data/admin_analytics/database/tables/base.py,sha256=1KyKsC18pW3m-5U-T6pdt5rIwsz6Wp3QFFbD3r6L6YQ,395
|
24
24
|
enterprise_data/admin_analytics/database/tables/fact_engagement_admin_dash.py,sha256=EsG8KgRW84wtA_nJuUknjLYlDtaPSJf_9mWdkO2Bj2I,1293
|
25
|
-
enterprise_data/admin_analytics/database/tables/fact_enrollment_admin_dash.py,sha256=
|
25
|
+
enterprise_data/admin_analytics/database/tables/fact_enrollment_admin_dash.py,sha256=ix5QvPnrUZMVs_Fdt742i9PAmrQTXuqHlfW3PJhSQWo,7282
|
26
26
|
enterprise_data/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
27
27
|
enterprise_data/api/urls.py,sha256=POqc_KATHdnpMf9zHtpO46pKD5KAlAExtx7G6iylLcU,273
|
28
28
|
enterprise_data/api/v0/__init__.py,sha256=1aAzAYU5hk-RW6cKUxa1645cbZMxn7GIZ7OMjWc9MKI,46
|
@@ -31,13 +31,13 @@ enterprise_data/api/v0/urls.py,sha256=vzJjqIo_S3AXWs9Us8XTaJc3FnxLbYzAkmLyuDQqum
|
|
31
31
|
enterprise_data/api/v0/views.py,sha256=4RslZ4NZOU-844bnebEQ71ji2utRY7jEijqC45oQQD0,14380
|
32
32
|
enterprise_data/api/v1/__init__.py,sha256=1aAzAYU5hk-RW6cKUxa1645cbZMxn7GIZ7OMjWc9MKI,46
|
33
33
|
enterprise_data/api/v1/paginators.py,sha256=f0xsilLaU94jSBltJk46tR1rLEIt7YrqSzMAAVtPXjA,3592
|
34
|
-
enterprise_data/api/v1/serializers.py,sha256=
|
35
|
-
enterprise_data/api/v1/urls.py,sha256=
|
34
|
+
enterprise_data/api/v1/serializers.py,sha256=FEuH-_iTlKFJoxM8ABzEYHDAWHsgSYfdQd2WSPdXoeE,13329
|
35
|
+
enterprise_data/api/v1/urls.py,sha256=JzaaVsHEAELMLFdhR60_8LRLb9klDeA4zdH1dLxnQEo,4048
|
36
36
|
enterprise_data/api/v1/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
37
37
|
enterprise_data/api/v1/views/analytics_engagements.py,sha256=8H3Fk-hTqJaU3H5Lpu1kFWq7p7xDp1pjge-sK7osDbg,17553
|
38
|
-
enterprise_data/api/v1/views/analytics_enrollments.py,sha256=
|
38
|
+
enterprise_data/api/v1/views/analytics_enrollments.py,sha256=uVc36C0s9y1dPBfbYwFhXLqPlExvQvZeiJ4C45lb4ZQ,6447
|
39
39
|
enterprise_data/api/v1/views/analytics_leaderboard.py,sha256=2DALqzUIbe4-ZGgHHIkYAKJ5L1ik2ruPtQNYtTdPba4,5974
|
40
|
-
enterprise_data/api/v1/views/base.py,sha256=
|
40
|
+
enterprise_data/api/v1/views/base.py,sha256=Kkmd5zgEBAhvwS_GoGXSK6lgbDNwSPioYn-QbnizI3w,3416
|
41
41
|
enterprise_data/api/v1/views/enterprise_admin.py,sha256=d79WkyBbqLk6pKGWWrnbYdrbOgQztP03Q7NKRA5tZB4,8639
|
42
42
|
enterprise_data/api/v1/views/enterprise_completions.py,sha256=bJG2ZtTbLyiBrj64iJHQNHEKLrJCzl9OuJ7nDtw-9aY,8377
|
43
43
|
enterprise_data/api/v1/views/enterprise_learner.py,sha256=yABjJje3CT8I8YOhWr1_tTkdKtnGJom8eu3EFz_-0BU,18517
|
@@ -113,10 +113,10 @@ enterprise_data/tests/test_models.py,sha256=MWBY-LY5TPBjZ4GlvpM-h4W-BvRKr2Rml8Bz
|
|
113
113
|
enterprise_data/tests/test_utils.py,sha256=vbmYM7DMN-lHS2p4yaa0Yd6uSGXd2qoZRDE9X3J4Sec,18385
|
114
114
|
enterprise_data/tests/test_views.py,sha256=UvDRNTxruy5zBK_KgUy2cBMbwlaTW_vkM0-TCXbQZiY,69667
|
115
115
|
enterprise_data/tests/admin_analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
116
|
-
enterprise_data/tests/admin_analytics/mock_analytics_data.py,sha256=
|
116
|
+
enterprise_data/tests/admin_analytics/mock_analytics_data.py,sha256=VZHhMKSQn5S-o1wNReSVyEnI_-6EkW-TUV5EvXCl5is,19536
|
117
117
|
enterprise_data/tests/admin_analytics/mock_enrollments.py,sha256=LfuMo9Kn-OQD4z42G3BRuM5MXUUXXlaAMhTqfJf46XE,7266
|
118
118
|
enterprise_data/tests/admin_analytics/test_analytics_engagements.py,sha256=KPXtBPaAOrzfff7W-xERSGx9KtZAJndLbIJx3gopSnE,15689
|
119
|
-
enterprise_data/tests/admin_analytics/test_analytics_enrollments.py,sha256=
|
119
|
+
enterprise_data/tests/admin_analytics/test_analytics_enrollments.py,sha256=GePv5E8BRGRWUHUwGaXvYIsN3dtDpNXUh-yfW5iBTi4,13781
|
120
120
|
enterprise_data/tests/admin_analytics/test_analytics_leaderboard.py,sha256=VSEyDAHfWBJvqmx9yzd4NnPAqK3TqaKrMBWswMAdzfU,6206
|
121
121
|
enterprise_data/tests/admin_analytics/test_data_loaders.py,sha256=o3denJ4aUS1pI5Crksl4C6m-NtCBm8ynoHBnLkf-v2U,4641
|
122
122
|
enterprise_data/tests/admin_analytics/test_enterprise_completions.py,sha256=afkHQFy4bvqZ0pq5Drl1t2nv8zxbgca2jzOQbihlPG0,7359
|
@@ -169,8 +169,8 @@ enterprise_reporting/tests/test_send_enterprise_reports.py,sha256=WtL-RqGgu2x5PP
|
|
169
169
|
enterprise_reporting/tests/test_utils.py,sha256=Zt_TA0LVb-B6fQGkUkAKKVlUKKnQh8jnw1US1jKe7g8,9493
|
170
170
|
enterprise_reporting/tests/test_vertica_client.py,sha256=-R2yNCGUjRtoXwLMBloVFQkFYrJoo613VCr61gwI3kQ,140
|
171
171
|
enterprise_reporting/tests/utils.py,sha256=xms2LM7DV3wczXEfctOK1ddel1EE0J_YSr17UzbCDy4,1401
|
172
|
-
edx_enterprise_data-8.
|
173
|
-
edx_enterprise_data-8.
|
174
|
-
edx_enterprise_data-8.
|
175
|
-
edx_enterprise_data-8.
|
176
|
-
edx_enterprise_data-8.
|
172
|
+
edx_enterprise_data-8.12.1.dist-info/LICENSE,sha256=dql8h4yceoMhuzlcK0TT_i-NgTFNIZsgE47Q4t3dUYI,34520
|
173
|
+
edx_enterprise_data-8.12.1.dist-info/METADATA,sha256=zAJ9YvmaCGHDChLF25Cf1zmK3Okmbq3qIG7UJ6E7xuw,1570
|
174
|
+
edx_enterprise_data-8.12.1.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
|
175
|
+
edx_enterprise_data-8.12.1.dist-info/top_level.txt,sha256=f5F2kU-dob6MqiHJpgZkFzoCD5VMhsdpkTV5n9Tvq3I,59
|
176
|
+
edx_enterprise_data-8.12.1.dist-info/RECORD,,
|
enterprise_data/__init__.py
CHANGED
@@ -7,6 +7,30 @@ class FactEnrollmentAdminDashQueries:
|
|
7
7
|
"""
|
8
8
|
Queries related to the fact_enrollment_admin_dash table.
|
9
9
|
"""
|
10
|
+
@staticmethod
|
11
|
+
def get_enrollment_count_query():
|
12
|
+
"""
|
13
|
+
Get the query to fetch the total number of enrollments for an enterprise customer.
|
14
|
+
"""
|
15
|
+
return """
|
16
|
+
SELECT count(*)
|
17
|
+
FROM fact_enrollment_admin_dash
|
18
|
+
WHERE enterprise_customer_uuid=%(enterprise_customer_uuid)s AND
|
19
|
+
enterprise_enrollment_date BETWEEN %(start_date)s AND %(end_date)s;
|
20
|
+
"""
|
21
|
+
|
22
|
+
@staticmethod
|
23
|
+
def get_all_enrollments_query():
|
24
|
+
"""
|
25
|
+
Get the query to fetch all enrollments.
|
26
|
+
"""
|
27
|
+
return """
|
28
|
+
SELECT email, course_title, course_subject, enroll_type, enterprise_enrollment_date
|
29
|
+
FROM fact_enrollment_admin_dash
|
30
|
+
WHERE enterprise_customer_uuid=%(enterprise_customer_uuid)s AND
|
31
|
+
enterprise_enrollment_date BETWEEN %(start_date)s AND %(end_date)s
|
32
|
+
ORDER BY ENTERPRISE_ENROLLMENT_DATE DESC LIMIT %(limit)s OFFSET %(offset)s
|
33
|
+
"""
|
10
34
|
|
11
35
|
@staticmethod
|
12
36
|
def get_enrollment_date_range_query():
|
@@ -59,3 +83,55 @@ class FactEnrollmentAdminDashQueries:
|
|
59
83
|
WHERE enterprise_customer_uuid=%(enterprise_customer_uuid)s AND
|
60
84
|
activity_date BETWEEN %(start_date)s AND %(end_date)s;
|
61
85
|
"""
|
86
|
+
|
87
|
+
@staticmethod
|
88
|
+
def get_top_courses_enrollments_query(record_count=20):
|
89
|
+
"""
|
90
|
+
Get the query to fetch the enrollment count by courses.
|
91
|
+
|
92
|
+
Query will fetch the top N courses by enrollment count. Where N is the value of record_count.
|
93
|
+
|
94
|
+
Arguments:
|
95
|
+
record_count (int): Number of records to fetch.
|
96
|
+
"""
|
97
|
+
# Some local environments raise error when course_title is added in SELECT without GROUP BY.
|
98
|
+
# If you face this issue, you can remove course_title from SELECT.
|
99
|
+
return f"""
|
100
|
+
SELECT course_key, course_title , enroll_type, count(course_key) as enrollment_count
|
101
|
+
FROM fact_enrollment_admin_dash
|
102
|
+
WHERE enterprise_customer_uuid=%(enterprise_customer_uuid)s AND
|
103
|
+
enterprise_enrollment_date BETWEEN %(start_date)s AND %(end_date)s
|
104
|
+
GROUP BY course_key, enroll_type ORDER BY enrollment_count DESC LIMIT {record_count};
|
105
|
+
"""
|
106
|
+
|
107
|
+
@staticmethod
|
108
|
+
def get_top_subjects_by_enrollments_query(record_count=20):
|
109
|
+
"""
|
110
|
+
Get the query to fetch the enrollment count by subjects.
|
111
|
+
|
112
|
+
Query will fetch the top N subjects by enrollment count. Where N is the value of record_count.
|
113
|
+
|
114
|
+
Arguments:
|
115
|
+
record_count (int): Number of records to fetch.
|
116
|
+
"""
|
117
|
+
return f"""
|
118
|
+
SELECT course_subject, enroll_type, count(course_subject) enrollment_count
|
119
|
+
FROM fact_enrollment_admin_dash
|
120
|
+
WHERE enterprise_customer_uuid=%(enterprise_customer_uuid)s AND
|
121
|
+
enterprise_enrollment_date BETWEEN %(start_date)s AND %(end_date)s
|
122
|
+
GROUP BY course_subject, enroll_type ORDER BY enrollment_count DESC LIMIT {record_count};
|
123
|
+
"""
|
124
|
+
|
125
|
+
@staticmethod
|
126
|
+
def get_enrolment_time_series_data_query():
|
127
|
+
"""
|
128
|
+
Get the query to fetch the enrollment time series data with daily aggregation.
|
129
|
+
"""
|
130
|
+
return """
|
131
|
+
SELECT enterprise_enrollment_date, enroll_type, COUNT(*) as enrollment_count
|
132
|
+
FROM fact_enrollment_admin_dash
|
133
|
+
WHERE enterprise_customer_uuid=%(enterprise_customer_uuid)s AND
|
134
|
+
enterprise_enrollment_date BETWEEN %(start_date)s AND %(end_date)s
|
135
|
+
GROUP BY enterprise_enrollment_date, enroll_type
|
136
|
+
ORDER BY enterprise_enrollment_date;
|
137
|
+
"""
|
@@ -15,6 +15,56 @@ class FactEnrollmentAdminDashTable(BaseTable):
|
|
15
15
|
"""
|
16
16
|
queries = FactEnrollmentAdminDashQueries()
|
17
17
|
|
18
|
+
def get_enrollment_count(self, enterprise_customer_uuid: UUID, start_date: date, end_date: date):
|
19
|
+
"""
|
20
|
+
Get the total number of enrollments for the given enterprise customer.
|
21
|
+
|
22
|
+
Arguments:
|
23
|
+
enterprise_customer_uuid (UUID): The UUID of the enterprise customer.
|
24
|
+
start_date (date): The start date.
|
25
|
+
end_date (date): The end date.
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
(int): The total number of enrollments.
|
29
|
+
"""
|
30
|
+
results = run_query(
|
31
|
+
query=self.queries.get_enrollment_count_query(),
|
32
|
+
params={
|
33
|
+
'enterprise_customer_uuid': enterprise_customer_uuid,
|
34
|
+
'start_date': start_date,
|
35
|
+
'end_date': end_date,
|
36
|
+
}
|
37
|
+
)
|
38
|
+
return results[0][0]
|
39
|
+
|
40
|
+
def get_all_enrollments(
|
41
|
+
self, enterprise_customer_uuid: UUID, start_date: date, end_date: date, limit: int, offset: int
|
42
|
+
):
|
43
|
+
"""
|
44
|
+
Get all enrollments for the given enterprise customer.
|
45
|
+
|
46
|
+
Arguments:
|
47
|
+
enterprise_customer_uuid (UUID): The UUID of the enterprise customer.
|
48
|
+
start_date (date): The start date.
|
49
|
+
end_date (date): The end date.
|
50
|
+
limit (int): The maximum number of records to return.
|
51
|
+
offset (int): The number of records to skip.
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
list<dict>: A list of dictionaries containing the enrollment data.
|
55
|
+
"""
|
56
|
+
return run_query(
|
57
|
+
query=self.queries.get_all_enrollments_query(),
|
58
|
+
params={
|
59
|
+
'enterprise_customer_uuid': enterprise_customer_uuid,
|
60
|
+
'start_date': start_date,
|
61
|
+
'end_date': end_date,
|
62
|
+
'limit': limit,
|
63
|
+
'offset': offset,
|
64
|
+
},
|
65
|
+
as_dict=True,
|
66
|
+
)
|
67
|
+
|
18
68
|
def get_enrollment_date_range(self, enterprise_customer_uuid: UUID):
|
19
69
|
"""
|
20
70
|
Get the enrollment date range for the given enterprise customer.
|
@@ -83,3 +133,69 @@ class FactEnrollmentAdminDashTable(BaseTable):
|
|
83
133
|
}
|
84
134
|
)
|
85
135
|
return results[0][0]
|
136
|
+
|
137
|
+
def get_top_courses_by_enrollments(self, enterprise_customer_uuid: UUID, start_date: date, end_date: date):
|
138
|
+
"""
|
139
|
+
Get the top courses enrollments for the given enterprise customer.
|
140
|
+
|
141
|
+
Arguments:
|
142
|
+
enterprise_customer_uuid (UUID): The UUID of the enterprise customer.
|
143
|
+
start_date (date): The start date.
|
144
|
+
end_date (date): The end date.
|
145
|
+
|
146
|
+
Returns:
|
147
|
+
list<dict>: A list of dictionaries containing the course key, course_title and enrollment count.
|
148
|
+
"""
|
149
|
+
return run_query(
|
150
|
+
query=self.queries.get_top_courses_enrollments_query(),
|
151
|
+
params={
|
152
|
+
'enterprise_customer_uuid': enterprise_customer_uuid,
|
153
|
+
'start_date': start_date,
|
154
|
+
'end_date': end_date,
|
155
|
+
},
|
156
|
+
as_dict=True,
|
157
|
+
)
|
158
|
+
|
159
|
+
def get_top_subjects_by_enrollments(self, enterprise_customer_uuid: UUID, start_date: date, end_date: date):
|
160
|
+
"""
|
161
|
+
Get the top subjects by enrollments for the given enterprise customer.
|
162
|
+
|
163
|
+
Arguments:
|
164
|
+
enterprise_customer_uuid (UUID): The UUID of the enterprise customer.
|
165
|
+
start_date (date): The start date.
|
166
|
+
end_date (date): The end date.
|
167
|
+
|
168
|
+
Returns:
|
169
|
+
list<dict>: A list of dictionaries containing the subject and enrollment count.
|
170
|
+
"""
|
171
|
+
return run_query(
|
172
|
+
query=self.queries.get_top_subjects_by_enrollments_query(),
|
173
|
+
params={
|
174
|
+
'enterprise_customer_uuid': enterprise_customer_uuid,
|
175
|
+
'start_date': start_date,
|
176
|
+
'end_date': end_date,
|
177
|
+
},
|
178
|
+
as_dict=True,
|
179
|
+
)
|
180
|
+
|
181
|
+
def get_enrolment_time_series_data(self, enterprise_customer_uuid: UUID, start_date: date, end_date: date):
|
182
|
+
"""
|
183
|
+
Get the enrollment time series data for the given enterprise customer.
|
184
|
+
|
185
|
+
Arguments:
|
186
|
+
enterprise_customer_uuid (UUID): The UUID of the enterprise customer.
|
187
|
+
start_date (date): The start date.
|
188
|
+
end_date (date): The end date.
|
189
|
+
|
190
|
+
Returns:
|
191
|
+
list<dict>: A list of dictionaries containing the date and enrollment count.
|
192
|
+
"""
|
193
|
+
return run_query(
|
194
|
+
query=self.queries.get_enrolment_time_series_data_query(),
|
195
|
+
params={
|
196
|
+
'enterprise_customer_uuid': enterprise_customer_uuid,
|
197
|
+
'start_date': start_date,
|
198
|
+
'end_date': end_date,
|
199
|
+
},
|
200
|
+
as_dict=True,
|
201
|
+
)
|
@@ -30,22 +30,27 @@ def get_db_connection(database=settings.ENTERPRISE_REPORTING_DB_ALIAS):
|
|
30
30
|
|
31
31
|
|
32
32
|
@timeit
|
33
|
-
def run_query(query, params: dict = None):
|
33
|
+
def run_query(query, params: dict = None, as_dict=False):
|
34
34
|
"""
|
35
35
|
Run a query on the database and return the results.
|
36
36
|
|
37
37
|
Arguments:
|
38
38
|
query (str): The query to run.
|
39
39
|
params (dict): The parameters to pass to the query.
|
40
|
+
as_dict (bool): When True, return the results as a dictionary.
|
40
41
|
|
41
42
|
Returns:
|
42
|
-
(list): The results of the query.
|
43
|
+
(list | dict): The results of the query.
|
43
44
|
"""
|
44
45
|
try:
|
45
46
|
with closing(get_db_connection()) as connection:
|
46
47
|
with closing(connection.cursor()) as cursor:
|
47
48
|
cursor.execute(query, params=params)
|
48
|
-
|
49
|
+
if as_dict:
|
50
|
+
columns = [column[0] for column in cursor.description]
|
51
|
+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
52
|
+
else:
|
53
|
+
return cursor.fetchall()
|
49
54
|
except Exception:
|
50
55
|
LOGGER.exception(f'[run_query]: run_query failed for query "{query}".')
|
51
56
|
raise
|
@@ -265,6 +265,8 @@ class AdvanceAnalyticsQueryParamSerializer(serializers.Serializer): # pylint: d
|
|
265
265
|
granularity = serializers.CharField(required=False)
|
266
266
|
calculation = serializers.CharField(required=False)
|
267
267
|
response_type = serializers.CharField(required=False)
|
268
|
+
page = serializers.IntegerField(required=False, min_value=1)
|
269
|
+
page_size = serializers.IntegerField(required=False, min_value=2)
|
268
270
|
|
269
271
|
def validate(self, attrs):
|
270
272
|
"""
|
enterprise_data/api/v1/urls.py
CHANGED
@@ -15,10 +15,7 @@ from enterprise_data.api.v1.views.analytics_engagements import (
|
|
15
15
|
AdvanceAnalyticsEngagementStatsView,
|
16
16
|
AdvanceAnalyticsIndividualEngagementsView,
|
17
17
|
)
|
18
|
-
from enterprise_data.api.v1.views.analytics_enrollments import
|
19
|
-
AdvanceAnalyticsEnrollmentStatsView,
|
20
|
-
AdvanceAnalyticsIndividualEnrollmentsView,
|
21
|
-
)
|
18
|
+
from enterprise_data.api.v1.views.analytics_enrollments import AdvanceAnalyticsEnrollmentsView
|
22
19
|
from enterprise_data.api.v1.views.analytics_leaderboard import AdvanceAnalyticsLeaderboardView
|
23
20
|
from enterprise_data.constants import UUID4_REGEX
|
24
21
|
|
@@ -69,12 +66,12 @@ urlpatterns = [
|
|
69
66
|
),
|
70
67
|
re_path(
|
71
68
|
fr'^admin/analytics/(?P<enterprise_uuid>{UUID4_REGEX})/enrollments/stats$',
|
72
|
-
|
69
|
+
AdvanceAnalyticsEnrollmentsView.as_view({'get': 'stats'}),
|
73
70
|
name='enterprise-admin-analytics-enrollments-stats'
|
74
71
|
),
|
75
72
|
re_path(
|
76
73
|
fr'^admin/analytics/(?P<enterprise_uuid>{UUID4_REGEX})/enrollments$',
|
77
|
-
|
74
|
+
AdvanceAnalyticsEnrollmentsView.as_view({'get': 'list'}),
|
78
75
|
name='enterprise-admin-analytics-enrollments'
|
79
76
|
),
|
80
77
|
re_path(
|
@@ -1,52 +1,74 @@
|
|
1
|
-
"""
|
1
|
+
"""
|
2
|
+
Advance Analytics for API endpoints to fetch enterprise enrollments data.
|
3
|
+
"""
|
2
4
|
from datetime import datetime
|
3
5
|
from logging import getLogger
|
4
6
|
|
5
7
|
from edx_rbac.decorators import permission_required
|
6
8
|
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
9
|
+
from rest_framework.decorators import action
|
7
10
|
from rest_framework.response import Response
|
8
|
-
from rest_framework.
|
11
|
+
from rest_framework.viewsets import ViewSet
|
9
12
|
|
10
|
-
from django.http import
|
13
|
+
from django.http import StreamingHttpResponse
|
11
14
|
|
12
|
-
from enterprise_data.admin_analytics.constants import
|
13
|
-
from enterprise_data.admin_analytics.
|
14
|
-
calculation_aggregation,
|
15
|
-
fetch_and_cache_enrollments_data,
|
16
|
-
fetch_enrollments_cache_expiry_timestamp,
|
17
|
-
granularity_aggregation,
|
18
|
-
)
|
15
|
+
from enterprise_data.admin_analytics.constants import ResponseType
|
16
|
+
from enterprise_data.admin_analytics.database.tables import FactEnrollmentAdminDashTable
|
19
17
|
from enterprise_data.api.v1.paginators import AdvanceAnalyticsPagination
|
20
18
|
from enterprise_data.api.v1.serializers import (
|
21
19
|
AdvanceAnalyticsEnrollmentStatsSerializer,
|
22
20
|
AdvanceAnalyticsQueryParamSerializer,
|
23
21
|
)
|
22
|
+
from enterprise_data.api.v1.views.base import AnalyticsPaginationMixin
|
24
23
|
from enterprise_data.renderers import IndividualEnrollmentsCSVRenderer
|
25
|
-
from enterprise_data.utils import
|
24
|
+
from enterprise_data.utils import timer
|
26
25
|
|
27
26
|
LOGGER = getLogger(__name__)
|
28
27
|
|
29
28
|
|
30
|
-
class
|
29
|
+
class AdvanceAnalyticsEnrollmentsView(AnalyticsPaginationMixin, ViewSet):
|
31
30
|
"""
|
32
|
-
|
31
|
+
View to handle requests for enterprise enrollments data.
|
32
|
+
|
33
|
+
Here is the list of URLs that are handled by this view:
|
34
|
+
1. `enterprise_data_api_v1.enterprise-learner-enrollment-list`: Get individual enrollment data.
|
35
|
+
2. `enterprise_data_api_v1.enterprise-learner-enrollment-stats`: Get enrollment stats data.
|
33
36
|
"""
|
34
37
|
authentication_classes = (JwtAuthentication,)
|
35
38
|
pagination_class = AdvanceAnalyticsPagination
|
36
|
-
http_method_names =
|
39
|
+
http_method_names = ('get', )
|
37
40
|
|
38
41
|
@permission_required('can_access_enterprise', fn=lambda request, enterprise_uuid: enterprise_uuid)
|
39
|
-
def
|
40
|
-
"""
|
42
|
+
def list(self, request, enterprise_uuid):
|
43
|
+
"""
|
44
|
+
Get individual enrollments data for the enterprise.
|
45
|
+
"""
|
46
|
+
# Remove hyphens from the UUID
|
47
|
+
enterprise_uuid = enterprise_uuid.replace('-', '')
|
48
|
+
|
41
49
|
serializer = AdvanceAnalyticsQueryParamSerializer(data=request.GET)
|
42
50
|
serializer.is_valid(raise_exception=True)
|
43
|
-
|
44
|
-
|
45
|
-
|
51
|
+
min_enrollment_date, _ = FactEnrollmentAdminDashTable().get_enrollment_date_range(
|
52
|
+
enterprise_uuid,
|
53
|
+
)
|
46
54
|
|
47
55
|
# get values from query params or use default values
|
48
|
-
start_date = serializer.data.get('start_date',
|
56
|
+
start_date = serializer.data.get('start_date', min_enrollment_date)
|
49
57
|
end_date = serializer.data.get('end_date', datetime.now())
|
58
|
+
page = serializer.data.get('page', 1)
|
59
|
+
page_size = serializer.data.get('page_size', 100)
|
60
|
+
enrollments = FactEnrollmentAdminDashTable().get_all_enrollments(
|
61
|
+
enterprise_customer_uuid=enterprise_uuid,
|
62
|
+
start_date=start_date,
|
63
|
+
end_date=end_date,
|
64
|
+
limit=page_size,
|
65
|
+
offset=(page - 1) * page_size,
|
66
|
+
)
|
67
|
+
total_count = FactEnrollmentAdminDashTable().get_enrollment_count(
|
68
|
+
enterprise_customer_uuid=enterprise_uuid,
|
69
|
+
start_date=start_date,
|
70
|
+
end_date=end_date,
|
71
|
+
)
|
50
72
|
response_type = request.query_params.get('response_type', ResponseType.JSON.value)
|
51
73
|
|
52
74
|
LOGGER.info(
|
@@ -56,333 +78,75 @@ class AdvanceAnalyticsIndividualEnrollmentsView(APIView):
|
|
56
78
|
end_date,
|
57
79
|
)
|
58
80
|
|
59
|
-
# filter enrollments by date
|
60
|
-
enrollments = date_filter(start_date, end_date, enrollments_df, "enterprise_enrollment_date")
|
61
|
-
|
62
|
-
# select only the columns that will be in the table.
|
63
|
-
enrollments = enrollments[
|
64
|
-
[
|
65
|
-
"email",
|
66
|
-
"course_title",
|
67
|
-
"course_subject",
|
68
|
-
"enroll_type",
|
69
|
-
"enterprise_enrollment_date",
|
70
|
-
]
|
71
|
-
]
|
72
|
-
enrollments["enterprise_enrollment_date"] = enrollments["enterprise_enrollment_date"].dt.date
|
73
|
-
enrollments = enrollments.sort_values(by="enterprise_enrollment_date", ascending=False).reset_index(drop=True)
|
74
|
-
|
75
|
-
LOGGER.info(
|
76
|
-
"Individual enrollments data prepared for enterprise [%s] from [%s] to [%s]",
|
77
|
-
enterprise_uuid,
|
78
|
-
start_date,
|
79
|
-
end_date,
|
80
|
-
)
|
81
|
-
|
82
81
|
if response_type == ResponseType.CSV.value:
|
83
82
|
filename = f"""individual_enrollments, {start_date} - {end_date}.csv"""
|
83
|
+
|
84
84
|
return StreamingHttpResponse(
|
85
|
-
IndividualEnrollmentsCSVRenderer().render(self._stream_serialized_data(
|
85
|
+
IndividualEnrollmentsCSVRenderer().render(self._stream_serialized_data(
|
86
|
+
enterprise_uuid, start_date, end_date, total_count
|
87
|
+
)),
|
86
88
|
content_type="text/csv",
|
87
89
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
88
90
|
)
|
89
91
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
92
|
+
return self.get_paginated_response(
|
93
|
+
request=request,
|
94
|
+
records=enrollments,
|
95
|
+
page=page,
|
96
|
+
page_size=page_size,
|
97
|
+
total_count=total_count,
|
98
|
+
)
|
96
99
|
|
97
|
-
|
100
|
+
@staticmethod
|
101
|
+
def _stream_serialized_data(enterprise_uuid, start_date, end_date, total_count, page_size=50000):
|
98
102
|
"""
|
99
103
|
Stream the serialized data.
|
100
104
|
"""
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
authentication_classes = (JwtAuthentication,)
|
113
|
-
http_method_names = ['get']
|
105
|
+
offset = 0
|
106
|
+
while offset < total_count:
|
107
|
+
enrollments = FactEnrollmentAdminDashTable().get_all_enrollments(
|
108
|
+
enterprise_customer_uuid=enterprise_uuid,
|
109
|
+
start_date=start_date,
|
110
|
+
end_date=end_date,
|
111
|
+
limit=page_size,
|
112
|
+
offset=offset,
|
113
|
+
)
|
114
|
+
yield from enrollments
|
115
|
+
offset += page_size
|
114
116
|
|
115
117
|
@permission_required('can_access_enterprise', fn=lambda request, enterprise_uuid: enterprise_uuid)
|
116
|
-
|
117
|
-
|
118
|
-
serializer = AdvanceAnalyticsEnrollmentStatsSerializer(data=request.GET)
|
119
|
-
serializer.is_valid(raise_exception=True)
|
120
|
-
|
121
|
-
cache_expiry = fetch_enrollments_cache_expiry_timestamp()
|
122
|
-
enrollments_df = fetch_and_cache_enrollments_data(enterprise_uuid, cache_expiry)
|
123
|
-
|
124
|
-
# get values from query params or use default
|
125
|
-
start_date = serializer.data.get('start_date', enrollments_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
|
-
response_type = serializer.data.get('response_type', ResponseType.JSON.value)
|
130
|
-
chart_type = serializer.data.get('chart_type')
|
131
|
-
|
132
|
-
# TODO: Add validation that if response_type is CSV then chart_type must be provided
|
133
|
-
|
134
|
-
if response_type == ResponseType.JSON.value:
|
135
|
-
with timer('construct_enrollment_all_stats'):
|
136
|
-
data = {
|
137
|
-
"enrollments_over_time": self.construct_enrollments_over_time(
|
138
|
-
enrollments_df.copy(),
|
139
|
-
start_date,
|
140
|
-
end_date,
|
141
|
-
granularity,
|
142
|
-
calculation,
|
143
|
-
),
|
144
|
-
"top_courses_by_enrollments": self.construct_top_courses_by_enrollments(
|
145
|
-
enrollments_df.copy(),
|
146
|
-
start_date,
|
147
|
-
end_date,
|
148
|
-
),
|
149
|
-
"top_subjects_by_enrollments": self.construct_top_subjects_by_enrollments(
|
150
|
-
enrollments_df.copy(),
|
151
|
-
start_date,
|
152
|
-
end_date,
|
153
|
-
),
|
154
|
-
}
|
155
|
-
return Response(data)
|
156
|
-
|
157
|
-
if response_type == ResponseType.CSV.value:
|
158
|
-
if chart_type == EnrollmentChart.ENROLLMENTS_OVER_TIME.value:
|
159
|
-
with timer('construct_enrollments_over_time_csv'):
|
160
|
-
return self.construct_enrollments_over_time_csv(
|
161
|
-
enrollments_df.copy(),
|
162
|
-
start_date,
|
163
|
-
end_date,
|
164
|
-
granularity,
|
165
|
-
calculation,
|
166
|
-
)
|
167
|
-
elif chart_type == EnrollmentChart.TOP_COURSES_BY_ENROLLMENTS.value:
|
168
|
-
with timer('construct_top_courses_by_enrollments_csv'):
|
169
|
-
return self.construct_top_courses_by_enrollments_csv(
|
170
|
-
enrollments_df.copy(),
|
171
|
-
start_date,
|
172
|
-
end_date,
|
173
|
-
)
|
174
|
-
elif chart_type == EnrollmentChart.TOP_SUBJECTS_BY_ENROLLMENTS.value:
|
175
|
-
with timer('construct_top_subjects_by_enrollments_csv'):
|
176
|
-
return self.construct_top_subjects_by_enrollments_csv(
|
177
|
-
enrollments_df.copy(),
|
178
|
-
start_date,
|
179
|
-
end_date,
|
180
|
-
)
|
181
|
-
|
182
|
-
def enrollments_over_time_common(self, enrollments_df, start_date, end_date, granularity, calculation):
|
183
|
-
"""
|
184
|
-
Common method for constructing enrollments over time data.
|
185
|
-
|
186
|
-
Arguments:
|
187
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
188
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
189
|
-
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
190
|
-
granularity {str} -- Granularity of the data. One of Granularity choices
|
191
|
-
calculation {str} -- Calculation of the data. One of Calculation choices
|
192
|
-
"""
|
193
|
-
# filter enrollments by date
|
194
|
-
enrollments = date_filter(start_date, end_date, enrollments_df, "enterprise_enrollment_date")
|
195
|
-
|
196
|
-
# aggregate enrollments by granularity
|
197
|
-
enrollments = granularity_aggregation(
|
198
|
-
level=granularity,
|
199
|
-
group=["enterprise_enrollment_date", "enroll_type"],
|
200
|
-
date="enterprise_enrollment_date",
|
201
|
-
data_frame=enrollments,
|
202
|
-
)
|
203
|
-
|
204
|
-
# aggregate enrollments by calculation
|
205
|
-
enrollments = calculation_aggregation(calc=calculation, data_frame=enrollments)
|
206
|
-
|
207
|
-
return enrollments
|
208
|
-
|
209
|
-
def construct_enrollments_over_time(self, enrollments_df, start_date, end_date, granularity, calculation):
|
210
|
-
"""
|
211
|
-
Construct enrollments over time data.
|
212
|
-
|
213
|
-
Arguments:
|
214
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
215
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
216
|
-
end_date {datetime} -- Enrollment 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
|
-
enrollments = self.enrollments_over_time_common(enrollments_df, start_date, end_date, granularity, calculation)
|
221
|
-
|
222
|
-
# convert dataframe to a list of records
|
223
|
-
return enrollments.to_dict(orient='records')
|
224
|
-
|
225
|
-
def construct_enrollments_over_time_csv(self, enrollments_df, start_date, end_date, granularity, calculation):
|
226
|
-
"""
|
227
|
-
Construct enrollments over time CSV.
|
228
|
-
|
229
|
-
Arguments:
|
230
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
231
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
232
|
-
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
233
|
-
granularity {str} -- Granularity of the data. One of Granularity choices
|
234
|
-
calculation {str} -- Calculation of the data. One of Calculation choices
|
118
|
+
@action(detail=False, methods=['get'], name='Charts Data', url_path='stats')
|
119
|
+
def stats(self, request, enterprise_uuid):
|
235
120
|
"""
|
236
|
-
|
121
|
+
Get data to populate enterprise enrollment charts.
|
237
122
|
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
filename = f"Enrollment Timeseries, {start_date} - {end_date} ({granularity} {calculation}).csv"
|
243
|
-
return self.construct_csv_response(enrollments, filename)
|
244
|
-
|
245
|
-
def top_courses_by_enrollments_common(self, enrollments_df, start_date, end_date, group_by_columns, columns):
|
246
|
-
"""
|
247
|
-
Common method for constructing top courses by enrollments data.
|
248
|
-
|
249
|
-
Arguments:
|
250
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
251
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
252
|
-
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
253
|
-
group_by_columns {list} -- List of columns to group by
|
254
|
-
columns {list} -- List of column for the final result
|
255
|
-
"""
|
256
|
-
# filter enrollments by date
|
257
|
-
enrollments = date_filter(start_date, end_date, enrollments_df, "enterprise_enrollment_date")
|
258
|
-
|
259
|
-
courses = list(
|
260
|
-
enrollments.groupby(["course_key"]).size().sort_values(ascending=False)[:10].index
|
261
|
-
)
|
262
|
-
|
263
|
-
enrollments = (
|
264
|
-
enrollments[enrollments.course_key.isin(courses)]
|
265
|
-
.groupby(group_by_columns)
|
266
|
-
.size()
|
267
|
-
.reset_index()
|
268
|
-
)
|
269
|
-
enrollments.columns = columns
|
270
|
-
|
271
|
-
return enrollments
|
272
|
-
|
273
|
-
def construct_top_courses_by_enrollments(self, enrollments_df, start_date, end_date):
|
274
|
-
"""
|
275
|
-
Construct top courses by enrollments data.
|
276
|
-
|
277
|
-
Arguments:
|
278
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
279
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
280
|
-
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
123
|
+
Here is the list of the charts and their corresponding data:
|
124
|
+
1. `enrollments_over_time`: This will show time series data of enrollments over time.
|
125
|
+
2. `top_courses_by_enrollments`: This will show the top courses by enrollments.
|
126
|
+
3. `top_subjects_by_enrollments`: This will show the top subjects by enrollments.
|
281
127
|
"""
|
282
|
-
|
283
|
-
|
284
|
-
enrollments = self.top_courses_by_enrollments_common(
|
285
|
-
enrollments_df,
|
286
|
-
start_date,
|
287
|
-
end_date,
|
288
|
-
group_by_columns,
|
289
|
-
columns
|
290
|
-
)
|
291
|
-
|
292
|
-
# convert dataframe to a list of records
|
293
|
-
return enrollments.to_dict(orient='records')
|
294
|
-
|
295
|
-
def construct_top_courses_by_enrollments_csv(self, enrollments_df, start_date, end_date):
|
296
|
-
"""
|
297
|
-
Construct top courses by enrollments CSV.
|
298
|
-
|
299
|
-
Arguments:
|
300
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
301
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
302
|
-
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
303
|
-
"""
|
304
|
-
group_by_columns = ["course_key", "course_title", "enroll_type"]
|
305
|
-
columns = ["course_key", "course_title", "enroll_type", "count"]
|
306
|
-
enrollments = self.top_courses_by_enrollments_common(
|
307
|
-
enrollments_df,
|
308
|
-
start_date,
|
309
|
-
end_date,
|
310
|
-
group_by_columns,
|
311
|
-
columns
|
312
|
-
)
|
313
|
-
|
314
|
-
enrollments = enrollments.pivot(
|
315
|
-
index=["course_key", "course_title"], columns="enroll_type", values="count"
|
316
|
-
)
|
128
|
+
# Remove hyphens from the UUID
|
129
|
+
enterprise_uuid = enterprise_uuid.replace('-', '')
|
317
130
|
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
def top_subjects_by_enrollments_common(self, enrollments_df, start_date, end_date):
|
322
|
-
"""
|
323
|
-
Common method for constructing top subjects by enrollments data.
|
324
|
-
|
325
|
-
Arguments:
|
326
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
327
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
328
|
-
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
329
|
-
"""
|
330
|
-
# filter enrollments by date
|
331
|
-
enrollments = date_filter(start_date, end_date, enrollments_df, "enterprise_enrollment_date")
|
332
|
-
|
333
|
-
subjects = list(
|
334
|
-
enrollments.groupby(["course_subject"]).size().sort_values(ascending=False)[:10].index
|
335
|
-
)
|
131
|
+
serializer = AdvanceAnalyticsEnrollmentStatsSerializer(data=request.GET)
|
132
|
+
serializer.is_valid(raise_exception=True)
|
336
133
|
|
337
|
-
|
338
|
-
|
339
|
-
.groupby(["course_subject", "enroll_type"])
|
340
|
-
.size()
|
341
|
-
.reset_index()
|
134
|
+
min_enrollment_date, _ = FactEnrollmentAdminDashTable().get_enrollment_date_range(
|
135
|
+
enterprise_uuid,
|
342
136
|
)
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
return
|
359
|
-
|
360
|
-
def construct_top_subjects_by_enrollments_csv(self, enrollments_df, start_date, end_date):
|
361
|
-
"""
|
362
|
-
Construct top subjects by enrollments CSV.
|
363
|
-
|
364
|
-
Arguments:
|
365
|
-
enrollments_df {DataFrame} -- DataFrame of enrollments
|
366
|
-
start_date {datetime} -- Enrollment start date in the format 'YYYY-MM-DD'
|
367
|
-
end_date {datetime} -- Enrollment end date in the format 'YYYY-MM-DD'
|
368
|
-
"""
|
369
|
-
enrollments = self.top_subjects_by_enrollments_common(enrollments_df, start_date, end_date)
|
370
|
-
enrollments = enrollments.pivot(index="course_subject", columns="enroll_type", values="count")
|
371
|
-
|
372
|
-
filename = f"Top 10 Subjects by Enrollment, {start_date} - {end_date}.csv"
|
373
|
-
return self.construct_csv_response(enrollments, filename)
|
374
|
-
|
375
|
-
def construct_csv_response(self, enrollments, filename):
|
376
|
-
"""
|
377
|
-
Construct CSV response.
|
378
|
-
|
379
|
-
Arguments:
|
380
|
-
enrollments {DataFrame} -- DataFrame of enrollments
|
381
|
-
filename {str} -- Filename for the CSV
|
382
|
-
"""
|
383
|
-
response = HttpResponse(content_type='text/csv')
|
384
|
-
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
385
|
-
response['Access-Control-Expose-Headers'] = 'Content-Disposition'
|
386
|
-
enrollments.to_csv(path_or_buf=response)
|
387
|
-
|
388
|
-
return response
|
137
|
+
# get values from query params or use default
|
138
|
+
start_date = serializer.data.get('start_date', min_enrollment_date)
|
139
|
+
end_date = serializer.data.get('end_date', datetime.now())
|
140
|
+
with timer('construct_enrollment_all_stats'):
|
141
|
+
data = {
|
142
|
+
'enrollments_over_time': FactEnrollmentAdminDashTable().get_enrolment_time_series_data(
|
143
|
+
enterprise_uuid, start_date, end_date
|
144
|
+
),
|
145
|
+
'top_courses_by_enrollments': FactEnrollmentAdminDashTable().get_top_courses_by_enrollments(
|
146
|
+
enterprise_uuid, start_date, end_date,
|
147
|
+
),
|
148
|
+
'top_subjects_by_enrollments': FactEnrollmentAdminDashTable().get_top_subjects_by_enrollments(
|
149
|
+
enterprise_uuid, start_date, end_date,
|
150
|
+
),
|
151
|
+
}
|
152
|
+
return Response(data)
|
@@ -1,9 +1,14 @@
|
|
1
1
|
"""
|
2
2
|
Base views for enterprise data api v1.
|
3
3
|
"""
|
4
|
+
import math
|
5
|
+
|
4
6
|
from edx_rbac.mixins import PermissionRequiredMixin
|
5
7
|
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
6
8
|
from edx_rest_framework_extensions.paginators import DefaultPagination
|
9
|
+
from rest_framework.exceptions import NotFound
|
10
|
+
from rest_framework.response import Response
|
11
|
+
from rest_framework.utils.urls import remove_query_param, replace_query_param
|
7
12
|
|
8
13
|
from enterprise_data.constants import ANALYTICS_API_VERSION_1
|
9
14
|
|
@@ -24,3 +29,77 @@ class EnterpriseViewSetMixin(PermissionRequiredMixin):
|
|
24
29
|
if 'no_page' in self.request.query_params:
|
25
30
|
return None
|
26
31
|
return super().paginate_queryset(queryset)
|
32
|
+
|
33
|
+
|
34
|
+
class AnalyticsPaginationMixin:
|
35
|
+
"""
|
36
|
+
Mixin that provides utility methods to allow pagination on views.
|
37
|
+
"""
|
38
|
+
page_query_param = 'page'
|
39
|
+
page_size_query_param = 'page_size'
|
40
|
+
|
41
|
+
def get_next_link(self, request, page_number, page_count):
|
42
|
+
"""
|
43
|
+
Get the link to the next page.
|
44
|
+
|
45
|
+
Arguments:
|
46
|
+
request (Request): The request object.
|
47
|
+
page_number (int): The current page number.
|
48
|
+
page_count (int): The total number of pages.
|
49
|
+
|
50
|
+
Returns:
|
51
|
+
str: The link to the next page.
|
52
|
+
"""
|
53
|
+
if page_number >= page_count:
|
54
|
+
return None
|
55
|
+
return replace_query_param(
|
56
|
+
url=request.build_absolute_uri(),
|
57
|
+
key=self.page_query_param,
|
58
|
+
val=page_number + 1,
|
59
|
+
)
|
60
|
+
|
61
|
+
def get_previous_link(self, request, page_number):
|
62
|
+
"""
|
63
|
+
Get the link to the previous page.
|
64
|
+
|
65
|
+
Arguments:
|
66
|
+
request (Request): The request object.
|
67
|
+
page_number (int): The current page number.
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
str: The link to the previous page.
|
71
|
+
"""
|
72
|
+
if page_number <= 1:
|
73
|
+
return None
|
74
|
+
url = request.build_absolute_uri()
|
75
|
+
next_page = page_number - 1
|
76
|
+
if next_page == 1:
|
77
|
+
return remove_query_param(url, self.page_query_param)
|
78
|
+
return replace_query_param(url, self.page_query_param, next_page)
|
79
|
+
|
80
|
+
def get_paginated_response(self, request, records, page, page_size, total_count):
|
81
|
+
"""
|
82
|
+
Get pagination data.
|
83
|
+
|
84
|
+
Arguments:
|
85
|
+
request (Request): The request object.
|
86
|
+
records (list): The records to return.
|
87
|
+
page (int): The current page number.
|
88
|
+
page_size (int): The number of records per page.
|
89
|
+
total_count (int): The total number of records
|
90
|
+
|
91
|
+
Returns:
|
92
|
+
(Response): The pagination data.
|
93
|
+
"""
|
94
|
+
page_count = math.ceil(total_count / page_size)
|
95
|
+
if page_count > 0 and (page <= 0 or page > page_count):
|
96
|
+
raise NotFound('Invalid page.')
|
97
|
+
|
98
|
+
return Response({
|
99
|
+
'next': self.get_next_link(request, page, page_count),
|
100
|
+
'previous': self.get_previous_link(request, page),
|
101
|
+
'count': total_count,
|
102
|
+
'num_pages': page_count,
|
103
|
+
'current_page': page,
|
104
|
+
'results': records,
|
105
|
+
})
|
@@ -384,18 +384,6 @@ def engagements_dataframe():
|
|
384
384
|
return engagements
|
385
385
|
|
386
386
|
|
387
|
-
def enrollments_csv_content():
|
388
|
-
"""Return the CSV content of enrollments."""
|
389
|
-
return (
|
390
|
-
b'email,course_title,course_subject,enroll_type,enterprise_enrollment_date\r\n'
|
391
|
-
b'rebeccanelson@example.com,Re-engineered tangible approach,business-management,certificate,2021-07-04\r\n'
|
392
|
-
b'taylorjames@example.com,Re-engineered tangible approach,business-management,certificate,2021-07-03\r\n'
|
393
|
-
b'ssmith@example.com,Secured static capability,medicine,certificate,2021-05-11\r\n'
|
394
|
-
b'amber79@example.com,Streamlined zero-defect attitude,communication,certificate,2020-04-08\r\n'
|
395
|
-
b'kathleenmartin@example.com,Horizontal solution-oriented hub,social-sciences,certificate,2020-04-03\r\n'
|
396
|
-
)
|
397
|
-
|
398
|
-
|
399
387
|
def leaderboard_csv_content():
|
400
388
|
"""Return the CSV content of leaderboard."""
|
401
389
|
# return (
|
@@ -1,5 +1,4 @@
|
|
1
1
|
"""Unittests for analytics_enrollments.py"""
|
2
|
-
|
3
2
|
from datetime import datetime
|
4
3
|
|
5
4
|
import ddt
|
@@ -8,15 +7,10 @@ from rest_framework import status
|
|
8
7
|
from rest_framework.reverse import reverse
|
9
8
|
from rest_framework.test import APITransactionTestCase
|
10
9
|
|
11
|
-
from enterprise_data.admin_analytics.constants import
|
10
|
+
from enterprise_data.admin_analytics.constants import ResponseType
|
12
11
|
from enterprise_data.api.v1.serializers import AdvanceAnalyticsEnrollmentStatsSerializer as EnrollmentStatsSerializer
|
13
12
|
from enterprise_data.api.v1.serializers import AdvanceAnalyticsQueryParamSerializer
|
14
|
-
from enterprise_data.tests.admin_analytics.mock_analytics_data import
|
15
|
-
ENROLLMENT_STATS_CSVS,
|
16
|
-
ENROLLMENTS,
|
17
|
-
enrollments_csv_content,
|
18
|
-
enrollments_dataframe,
|
19
|
-
)
|
13
|
+
from enterprise_data.tests.admin_analytics.mock_analytics_data import ENROLLMENTS
|
20
14
|
from enterprise_data.tests.mixins import JWTTestMixin
|
21
15
|
from enterprise_data.tests.test_utils import UserFactory
|
22
16
|
from enterprise_data_roles.constants import ENTERPRISE_DATA_ADMIN_ROLE
|
@@ -59,8 +53,8 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
59
53
|
)
|
60
54
|
|
61
55
|
fetch_max_enrollment_datetime_patcher = patch(
|
62
|
-
'enterprise_data.api.v1.views.analytics_enrollments.
|
63
|
-
return_value=datetime.now()
|
56
|
+
'enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_enrollment_date_range',
|
57
|
+
return_value=(datetime.now(), datetime.now())
|
64
58
|
)
|
65
59
|
|
66
60
|
fetch_max_enrollment_datetime_patcher.start()
|
@@ -69,11 +63,11 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
69
63
|
def verify_enrollment_data(self, results, results_count):
|
70
64
|
"""Verify the received enrollment data."""
|
71
65
|
attrs = [
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
66
|
+
'email',
|
67
|
+
'course_title',
|
68
|
+
'course_subject',
|
69
|
+
'enroll_type',
|
70
|
+
'enterprise_enrollment_date',
|
77
71
|
]
|
78
72
|
|
79
73
|
assert len(results) == results_count
|
@@ -89,16 +83,16 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
89
83
|
expected_data = sorted(filtered_data, key=lambda x: x["email"])
|
90
84
|
assert received_data == expected_data
|
91
85
|
|
92
|
-
@patch(
|
93
|
-
|
94
|
-
)
|
95
|
-
def test_get(self, mock_fetch_and_cache_enrollments_data):
|
86
|
+
@patch('enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_enrollment_count')
|
87
|
+
@patch('enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_all_enrollments')
|
88
|
+
def test_get(self, mock_get_all_enrollments, mock_get_enrollment_count):
|
96
89
|
"""
|
97
90
|
Test the GET method for the AdvanceAnalyticsIndividualEnrollmentsView works.
|
98
91
|
"""
|
99
|
-
|
92
|
+
mock_get_all_enrollments.return_value = ENROLLMENTS
|
93
|
+
mock_get_enrollment_count.return_value = len(ENROLLMENTS)
|
100
94
|
|
101
|
-
response = self.client.get(self.url
|
95
|
+
response = self.client.get(self.url + '?page_size=2')
|
102
96
|
assert response.status_code == status.HTTP_200_OK
|
103
97
|
assert response["Content-Type"] == "application/json"
|
104
98
|
data = response.json()
|
@@ -107,9 +101,8 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
107
101
|
assert data["current_page"] == 1
|
108
102
|
assert data["num_pages"] == 3
|
109
103
|
assert data["count"] == 5
|
110
|
-
self.verify_enrollment_data(data["results"], 2)
|
111
104
|
|
112
|
-
response = self.client.get(self.url
|
105
|
+
response = self.client.get(self.url + '?page_size=2&page=2')
|
113
106
|
assert response.status_code == status.HTTP_200_OK
|
114
107
|
assert response["Content-Type"] == "application/json"
|
115
108
|
data = response.json()
|
@@ -118,9 +111,8 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
118
111
|
assert data["current_page"] == 2
|
119
112
|
assert data["num_pages"] == 3
|
120
113
|
assert data["count"] == 5
|
121
|
-
self.verify_enrollment_data(data["results"], 2)
|
122
114
|
|
123
|
-
response = self.client.get(self.url
|
115
|
+
response = self.client.get(self.url + '?page_size=2&page=3')
|
124
116
|
assert response.status_code == status.HTTP_200_OK
|
125
117
|
assert response["Content-Type"] == "application/json"
|
126
118
|
data = response.json()
|
@@ -129,9 +121,8 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
129
121
|
assert data["current_page"] == 3
|
130
122
|
assert data["num_pages"] == 3
|
131
123
|
assert data["count"] == 5
|
132
|
-
self.verify_enrollment_data(data["results"], 1)
|
133
124
|
|
134
|
-
response = self.client.get(self.url
|
125
|
+
response = self.client.get(self.url + '?page_size=5')
|
135
126
|
assert response.status_code == status.HTTP_200_OK
|
136
127
|
assert response["Content-Type"] == "application/json"
|
137
128
|
data = response.json()
|
@@ -140,16 +131,15 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
140
131
|
assert data["current_page"] == 1
|
141
132
|
assert data["num_pages"] == 1
|
142
133
|
assert data["count"] == 5
|
143
|
-
self.verify_enrollment_data(data["results"], 5)
|
144
134
|
|
145
|
-
@patch(
|
146
|
-
|
147
|
-
)
|
148
|
-
def test_get_csv(self, mock_fetch_and_cache_enrollments_data):
|
135
|
+
@patch('enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_enrollment_count')
|
136
|
+
@patch('enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_all_enrollments')
|
137
|
+
def test_get_csv(self, mock_get_all_enrollments, mock_get_enrollment_count):
|
149
138
|
"""
|
150
139
|
Test the GET method for the AdvanceAnalyticsIndividualEnrollmentsView return correct CSV data.
|
151
140
|
"""
|
152
|
-
|
141
|
+
mock_get_all_enrollments.return_value = ENROLLMENTS
|
142
|
+
mock_get_enrollment_count.return_value = len(ENROLLMENTS)
|
153
143
|
response = self.client.get(self.url, {"response_type": ResponseType.CSV.value})
|
154
144
|
assert response.status_code == status.HTTP_200_OK
|
155
145
|
|
@@ -157,8 +147,33 @@ class TestIndividualEnrollmentsAPI(JWTTestMixin, APITransactionTestCase):
|
|
157
147
|
assert response["Content-Type"] == "text/csv"
|
158
148
|
|
159
149
|
# verify the response content
|
160
|
-
content = b"".join(response.streaming_content)
|
161
|
-
assert content ==
|
150
|
+
content = b"".join(response.streaming_content).decode().splitlines()
|
151
|
+
assert len(content) == 6
|
152
|
+
|
153
|
+
# Verify CSV header.
|
154
|
+
assert 'email,course_title,course_subject,enroll_type,enterprise_enrollment_date' == content[0]
|
155
|
+
|
156
|
+
# verify the content
|
157
|
+
assert (
|
158
|
+
'rebeccanelson@example.com,Re-engineered tangible approach,business-management,certificate,2021-07-04'
|
159
|
+
in content
|
160
|
+
)
|
161
|
+
assert (
|
162
|
+
'taylorjames@example.com,Re-engineered tangible approach,business-management,certificate,2021-07-03'
|
163
|
+
in content
|
164
|
+
)
|
165
|
+
assert (
|
166
|
+
'ssmith@example.com,Secured static capability,medicine,certificate,2021-05-11'
|
167
|
+
in content
|
168
|
+
)
|
169
|
+
assert (
|
170
|
+
'amber79@example.com,Streamlined zero-defect attitude,communication,certificate,2020-04-08'
|
171
|
+
in content
|
172
|
+
)
|
173
|
+
assert (
|
174
|
+
'kathleenmartin@example.com,Horizontal solution-oriented hub,social-sciences,certificate,2020-04-03'
|
175
|
+
in content
|
176
|
+
)
|
162
177
|
|
163
178
|
@ddt.data(
|
164
179
|
{
|
@@ -232,8 +247,8 @@ class TestEnrollmentStatsAPI(JWTTestMixin, APITransactionTestCase):
|
|
232
247
|
)
|
233
248
|
|
234
249
|
fetch_max_enrollment_datetime_patcher = patch(
|
235
|
-
'enterprise_data.api.v1.views.analytics_enrollments.
|
236
|
-
return_value=datetime.now()
|
250
|
+
'enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_enrollment_date_range',
|
251
|
+
return_value=(datetime.now(), datetime.now())
|
237
252
|
)
|
238
253
|
|
239
254
|
fetch_max_enrollment_datetime_patcher.start()
|
@@ -261,93 +276,35 @@ class TestEnrollmentStatsAPI(JWTTestMixin, APITransactionTestCase):
|
|
261
276
|
assert received_data == expected_data
|
262
277
|
|
263
278
|
@patch(
|
264
|
-
|
279
|
+
'enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.'
|
280
|
+
'get_top_subjects_by_enrollments'
|
281
|
+
)
|
282
|
+
@patch(
|
283
|
+
'enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_top_courses_by_enrollments'
|
284
|
+
)
|
285
|
+
@patch(
|
286
|
+
'enterprise_data.api.v1.views.analytics_enrollments.FactEnrollmentAdminDashTable.get_enrolment_time_series_data'
|
265
287
|
)
|
266
|
-
def test_get(
|
288
|
+
def test_get(
|
289
|
+
self,
|
290
|
+
mock_get_enrolment_time_series_data,
|
291
|
+
mock_get_top_courses_by_enrollments,
|
292
|
+
mock_get_top_subjects_by_enrollments,
|
293
|
+
):
|
267
294
|
"""
|
268
295
|
Test the GET method for the AdvanceAnalyticsEnrollmentStatsView works.
|
269
296
|
"""
|
270
|
-
|
297
|
+
mock_get_enrolment_time_series_data.return_value = []
|
298
|
+
mock_get_top_courses_by_enrollments.return_value = []
|
299
|
+
mock_get_top_subjects_by_enrollments.return_value = []
|
271
300
|
|
272
301
|
response = self.client.get(self.url)
|
273
302
|
assert response.status_code == status.HTTP_200_OK
|
274
303
|
assert response["Content-Type"] == "application/json"
|
275
304
|
data = response.json()
|
276
|
-
assert
|
277
|
-
|
278
|
-
|
279
|
-
"enterprise_enrollment_date": "2020-04-03T00:00:00",
|
280
|
-
"enroll_type": "certificate",
|
281
|
-
"count": 1,
|
282
|
-
},
|
283
|
-
{
|
284
|
-
"enterprise_enrollment_date": "2020-04-08T00:00:00",
|
285
|
-
"enroll_type": "certificate",
|
286
|
-
"count": 1,
|
287
|
-
},
|
288
|
-
{
|
289
|
-
"enterprise_enrollment_date": "2021-05-11T00:00:00",
|
290
|
-
"enroll_type": "certificate",
|
291
|
-
"count": 1,
|
292
|
-
},
|
293
|
-
{
|
294
|
-
"enterprise_enrollment_date": "2021-07-03T00:00:00",
|
295
|
-
"enroll_type": "certificate",
|
296
|
-
"count": 1,
|
297
|
-
},
|
298
|
-
{
|
299
|
-
"enterprise_enrollment_date": "2021-07-04T00:00:00",
|
300
|
-
"enroll_type": "certificate",
|
301
|
-
"count": 1,
|
302
|
-
},
|
303
|
-
],
|
304
|
-
"top_courses_by_enrollments": [
|
305
|
-
{"course_key": "NOGk+UVD31", "enroll_type": "certificate", "count": 1},
|
306
|
-
{"course_key": "QWXx+Jqz64", "enroll_type": "certificate", "count": 1},
|
307
|
-
{"course_key": "hEmW+tvk03", "enroll_type": "certificate", "count": 2},
|
308
|
-
{"course_key": "qZJC+KFX86", "enroll_type": "certificate", "count": 1},
|
309
|
-
],
|
310
|
-
"top_subjects_by_enrollments": [
|
311
|
-
{
|
312
|
-
"course_subject": "business-management",
|
313
|
-
"enroll_type": "certificate",
|
314
|
-
"count": 2,
|
315
|
-
},
|
316
|
-
{
|
317
|
-
"course_subject": "communication",
|
318
|
-
"enroll_type": "certificate",
|
319
|
-
"count": 1,
|
320
|
-
},
|
321
|
-
{
|
322
|
-
"course_subject": "medicine",
|
323
|
-
"enroll_type": "certificate",
|
324
|
-
"count": 1,
|
325
|
-
},
|
326
|
-
{
|
327
|
-
"course_subject": "social-sciences",
|
328
|
-
"enroll_type": "certificate",
|
329
|
-
"count": 1,
|
330
|
-
},
|
331
|
-
],
|
332
|
-
}
|
333
|
-
|
334
|
-
@patch("enterprise_data.api.v1.views.analytics_enrollments.fetch_and_cache_enrollments_data")
|
335
|
-
@ddt.data(
|
336
|
-
EnrollmentChart.ENROLLMENTS_OVER_TIME.value,
|
337
|
-
EnrollmentChart.TOP_COURSES_BY_ENROLLMENTS.value,
|
338
|
-
EnrollmentChart.TOP_SUBJECTS_BY_ENROLLMENTS.value,
|
339
|
-
)
|
340
|
-
def test_get_csv(self, chart_type, mock_fetch_and_cache_enrollments_data):
|
341
|
-
"""
|
342
|
-
Test that AdvanceAnalyticsEnrollmentStatsView return correct CSV data.
|
343
|
-
"""
|
344
|
-
mock_fetch_and_cache_enrollments_data.return_value = enrollments_dataframe()
|
345
|
-
|
346
|
-
response = self.client.get(self.url, {"response_type": ResponseType.CSV.value, "chart_type": chart_type})
|
347
|
-
assert response.status_code == status.HTTP_200_OK
|
348
|
-
assert response["Content-Type"] == "text/csv"
|
349
|
-
# verify the response content
|
350
|
-
assert response.content == ENROLLMENT_STATS_CSVS[chart_type]
|
305
|
+
assert 'enrollments_over_time' in data
|
306
|
+
assert 'top_courses_by_enrollments' in data
|
307
|
+
assert 'top_subjects_by_enrollments' in data
|
351
308
|
|
352
309
|
@ddt.data(
|
353
310
|
{
|
File without changes
|
File without changes
|
File without changes
|