edx-enterprise-data 8.9.0__py3-none-any.whl → 8.11.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.
Files changed (27) hide show
  1. {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.11.0.dist-info}/METADATA +1 -1
  2. {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.11.0.dist-info}/RECORD +27 -17
  3. enterprise_data/__init__.py +1 -1
  4. enterprise_data/admin_analytics/constants.py +8 -0
  5. enterprise_data/admin_analytics/database/__init__.py +4 -0
  6. enterprise_data/admin_analytics/database/queries/__init__.py +5 -0
  7. enterprise_data/admin_analytics/database/queries/fact_engagement_admin_dash.py +21 -0
  8. enterprise_data/admin_analytics/database/queries/fact_enrollment_admin_dash.py +61 -0
  9. enterprise_data/admin_analytics/database/tables/__init__.py +5 -0
  10. enterprise_data/admin_analytics/database/tables/base.py +18 -0
  11. enterprise_data/admin_analytics/database/tables/fact_engagement_admin_dash.py +38 -0
  12. enterprise_data/admin_analytics/database/tables/fact_enrollment_admin_dash.py +76 -0
  13. enterprise_data/admin_analytics/{database.py → database/utils.py} +3 -2
  14. enterprise_data/api/v1/serializers.py +31 -1
  15. enterprise_data/api/v1/urls.py +14 -0
  16. enterprise_data/api/v1/views/analytics_engagements.py +395 -0
  17. enterprise_data/api/v1/views/analytics_enrollments.py +1 -0
  18. enterprise_data/api/v1/views/analytics_leaderboard.py +4 -1
  19. enterprise_data/api/v1/views/enterprise_admin.py +15 -44
  20. enterprise_data/api/v1/views/enterprise_completions.py +2 -0
  21. enterprise_data/renderers.py +14 -0
  22. enterprise_data/tests/admin_analytics/mock_analytics_data.py +41 -1
  23. enterprise_data/tests/admin_analytics/test_analytics_engagements.py +390 -0
  24. enterprise_data/tests/api/v1/views/test_enterprise_admin.py +43 -20
  25. {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.11.0.dist-info}/LICENSE +0 -0
  26. {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.11.0.dist-info}/WHEEL +0 -0
  27. {edx_enterprise_data-8.9.0.dist-info → edx_enterprise_data-8.11.0.dist-info}/top_level.txt +0 -0
@@ -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
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Test cases for enterprise_admin views
3
3
  """
4
+ from datetime import datetime
4
5
  from unittest import mock
5
6
  from uuid import uuid4
6
7
 
@@ -11,10 +12,13 @@ from rest_framework import status
11
12
  from rest_framework.reverse import reverse
12
13
  from rest_framework.test import APITransactionTestCase
13
14
 
15
+ from enterprise_data.admin_analytics.database.queries import (
16
+ FactEngagementAdminDashQueries,
17
+ FactEnrollmentAdminDashQueries,
18
+ )
14
19
  from enterprise_data.tests.mixins import JWTTestMixin
15
20
  from enterprise_data.tests.test_utils import (
16
21
  UserFactory,
17
- get_dummy_engagements_data,
18
22
  get_dummy_enrollments_data,
19
23
  get_dummy_enterprise_api_data,
20
24
  get_dummy_skills_data,
@@ -52,19 +56,30 @@ class TestEnterpriseAdminAnalyticsAggregatesView(JWTTestMixin, APITransactionTes
52
56
  self.addCleanup(mocked_get_enterprise_customer.stop)
53
57
  self.enterprise_id = 'ee5e6b3a-069a-4947-bb8d-d2dbc323396c'
54
58
  self.set_jwt_cookie()
59
+ self.enrollment_queries = FactEnrollmentAdminDashQueries()
60
+ self.engagement_queries = FactEngagementAdminDashQueries()
55
61
 
56
- def _mock_run_query(self, query):
62
+ def _mock_run_query(self, query, *args, **kwargs):
57
63
  """
58
64
  mock implementation of run_query.
59
65
  """
60
- if 'fact_enrollment_admin_dash' in query:
61
- return [
62
- list(item.values()) for item in get_dummy_enrollments_data(self.enterprise_id, 15)
63
- ]
64
- else:
65
- return [
66
- list(item.values()) for item in get_dummy_engagements_data(self.enterprise_id, 15)
67
- ]
66
+ mock_responses = {
67
+ self.enrollment_queries.get_enrollment_date_range_query(): [[
68
+ datetime.strptime('2021-01-01', "%Y-%m-%d"),
69
+ datetime.strptime('2021-12-31', "%Y-%m-%d"),
70
+ ]],
71
+ self.enrollment_queries.get_enrollment_and_course_count_query(): [[
72
+ 100, 10
73
+ ]],
74
+ self.enrollment_queries.get_completion_count_query(): [[
75
+ 50
76
+ ]],
77
+ self.engagement_queries.get_learning_hours_and_daily_sessions_query(): [[
78
+ 100, 10
79
+ ]],
80
+ 'SELECT MAX(created) FROM enterprise_learner_enrollment': [[datetime.strptime('2021-01-01', "%Y-%m-%d")]]
81
+ }
82
+ return mock_responses[query]
68
83
 
69
84
  def test_get_admin_analytics_aggregates(self):
70
85
  """
@@ -72,16 +87,24 @@ class TestEnterpriseAdminAnalyticsAggregatesView(JWTTestMixin, APITransactionTes
72
87
  """
73
88
  url = reverse('v1:enterprise-admin-analytics-aggregates', kwargs={'enterprise_id': self.enterprise_id})
74
89
  with patch('enterprise_data.admin_analytics.data_loaders.run_query', side_effect=self._mock_run_query):
75
- response = self.client.get(url)
76
- assert response.status_code == status.HTTP_200_OK
77
- assert 'enrolls' in response.json()
78
- assert 'courses' in response.json()
79
- assert 'completions' in response.json()
80
- assert 'hours' in response.json()
81
- assert 'sessions' in response.json()
82
- assert 'last_updated_at' in response.json()
83
- assert 'min_enrollment_date' in response.json()
84
- assert 'max_enrollment_date' in response.json()
90
+ with patch(
91
+ 'enterprise_data.admin_analytics.database.tables.fact_engagement_admin_dash.run_query',
92
+ side_effect=self._mock_run_query
93
+ ):
94
+ with patch(
95
+ 'enterprise_data.admin_analytics.database.tables.fact_enrollment_admin_dash.run_query',
96
+ side_effect=self._mock_run_query
97
+ ):
98
+ response = self.client.get(url)
99
+ assert response.status_code == status.HTTP_200_OK
100
+ assert 'enrolls' in response.json()
101
+ assert 'courses' in response.json()
102
+ assert 'completions' in response.json()
103
+ assert 'hours' in response.json()
104
+ assert 'sessions' in response.json()
105
+ assert 'last_updated_at' in response.json()
106
+ assert 'min_enrollment_date' in response.json()
107
+ assert 'max_enrollment_date' in response.json()
85
108
 
86
109
 
87
110
  @ddt.ddt