openedx-webhooks 22.0.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.
- openedx_webhooks/__init__.py +7 -0
- openedx_webhooks/admin.py +40 -0
- openedx_webhooks/apps.py +130 -0
- openedx_webhooks/filters.py +2276 -0
- openedx_webhooks/migrations/0001_initial.py +30 -0
- openedx_webhooks/migrations/0002_webfilter.py +37 -0
- openedx_webhooks/migrations/0003_auto_20230607_1246.py +23 -0
- openedx_webhooks/migrations/0004_auto_20230613_1033.py +28 -0
- openedx_webhooks/migrations/0005_alter_webfilter_event.py +18 -0
- openedx_webhooks/migrations/0006_alter_webfilter_event.py +18 -0
- openedx_webhooks/migrations/0007_alter_webfilter_event.py +18 -0
- openedx_webhooks/migrations/0008_verawood.py +51 -0
- openedx_webhooks/migrations/__init__.py +0 -0
- openedx_webhooks/models.py +185 -0
- openedx_webhooks/receivers.py +434 -0
- openedx_webhooks/settings/__init__.py +0 -0
- openedx_webhooks/settings/cms.py +21 -0
- openedx_webhooks/settings/common.py +191 -0
- openedx_webhooks/settings/test.py +10 -0
- openedx_webhooks/urls.py +10 -0
- openedx_webhooks/utils.py +124 -0
- openedx_webhooks-22.0.0.dist-info/METADATA +415 -0
- openedx_webhooks-22.0.0.dist-info/RECORD +27 -0
- openedx_webhooks-22.0.0.dist-info/WHEEL +5 -0
- openedx_webhooks-22.0.0.dist-info/entry_points.txt +5 -0
- openedx_webhooks-22.0.0.dist-info/licenses/LICENSE.txt +674 -0
- openedx_webhooks-22.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Handlers for Open edX filters.
|
|
3
|
+
|
|
4
|
+
Signals:
|
|
5
|
+
CertificateCreationRequested,
|
|
6
|
+
CertificateRenderStarted,
|
|
7
|
+
CohortAssignmentRequested,
|
|
8
|
+
CohortChangeRequested,
|
|
9
|
+
CourseAboutRenderStarted,
|
|
10
|
+
CourseEnrollmentStarted,
|
|
11
|
+
CourseUnenrollmentStarted,
|
|
12
|
+
DashboardRenderStarted,
|
|
13
|
+
StudentLoginRequested,
|
|
14
|
+
StudentRegistrationRequested,
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from inspect import Parameter, signature
|
|
20
|
+
|
|
21
|
+
import requests.exceptions
|
|
22
|
+
from django.contrib.auth import get_user_model
|
|
23
|
+
from django.db import models
|
|
24
|
+
from django.http import HttpResponse
|
|
25
|
+
from django.utils import timezone
|
|
26
|
+
from opaque_keys.edx.keys import CourseKey
|
|
27
|
+
from openedx_filters import PipelineStep
|
|
28
|
+
from openedx_filters.learning.filters import (
|
|
29
|
+
AccountSettingsRenderStarted,
|
|
30
|
+
CertificateCreationRequested,
|
|
31
|
+
CertificateRenderStarted,
|
|
32
|
+
CohortAssignmentRequested,
|
|
33
|
+
CohortChangeRequested,
|
|
34
|
+
CourseAboutRenderStarted,
|
|
35
|
+
CourseEnrollmentQuerysetRequested,
|
|
36
|
+
CourseEnrollmentStarted,
|
|
37
|
+
CourseUnenrollmentStarted,
|
|
38
|
+
DashboardRenderStarted,
|
|
39
|
+
InstructorDashboardRenderStarted,
|
|
40
|
+
InstructorDashboardTabsRequested,
|
|
41
|
+
ORASubmissionViewRenderStarted,
|
|
42
|
+
RenderXBlockStarted,
|
|
43
|
+
StudentLoginRequested,
|
|
44
|
+
StudentRegistrationRequested,
|
|
45
|
+
VerticalBlockChildRenderStarted,
|
|
46
|
+
VerticalBlockRenderCompleted,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from .models import Webfilter
|
|
50
|
+
from .utils import SENSITIVE_FIELDS, object_serializer, redact_sensitive_fields, send
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_course_blocks_completion_summary(course_key, user):
|
|
56
|
+
"""Load the LMS completion helper only when a certificate filter needs it."""
|
|
57
|
+
# pylint: disable=import-error,import-outside-toplevel
|
|
58
|
+
from lms.djangoapps.courseware import courses
|
|
59
|
+
return courses.get_course_blocks_completion_summary(course_key, user)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fix_dict_keys(d: dict):
|
|
63
|
+
"""
|
|
64
|
+
Remove dict keys from a dict.
|
|
65
|
+
|
|
66
|
+
This fixes a problem with course objects, for which vars(course) or course.__dict__ return a dict
|
|
67
|
+
which contains dicts as keys, and are therefore not json serializable.
|
|
68
|
+
We convert these dicts in place of keys, to their str representation.
|
|
69
|
+
"""
|
|
70
|
+
# Initialize the response
|
|
71
|
+
r = {}
|
|
72
|
+
for k, v in d.items():
|
|
73
|
+
if isinstance(v, dict):
|
|
74
|
+
v = fix_dict_keys(v)
|
|
75
|
+
if type(k) not in [str, int, float]:
|
|
76
|
+
r[str(k)] = v
|
|
77
|
+
else:
|
|
78
|
+
r[k] = v
|
|
79
|
+
return r
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _process_filter(webfilters, data, exception=None):
|
|
83
|
+
"""
|
|
84
|
+
Process all events with user data.
|
|
85
|
+
"""
|
|
86
|
+
response_data = {}
|
|
87
|
+
response_exceptions = {}
|
|
88
|
+
|
|
89
|
+
payload = object_serializer(data)
|
|
90
|
+
|
|
91
|
+
for webfilter in webfilters:
|
|
92
|
+
logger.info(f"{webfilter.event} webhook filter triggered to {webfilter.webhook_url}")
|
|
93
|
+
|
|
94
|
+
# Add event metadata
|
|
95
|
+
payload['event_metadata'] = {
|
|
96
|
+
'event_type': webfilter.event,
|
|
97
|
+
'time': timezone.now().isoformat()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
# Send the request to the webhook URL
|
|
102
|
+
response = send(
|
|
103
|
+
webfilter.webhook_url,
|
|
104
|
+
payload,
|
|
105
|
+
www_form_urlencoded=webfilter.use_www_form_encoding,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
except requests.exceptions.RequestException as e:
|
|
109
|
+
if webfilter.halt_on_request_exception and exception:
|
|
110
|
+
_raise_filter_exception(
|
|
111
|
+
exception,
|
|
112
|
+
message=str(e),
|
|
113
|
+
redirect_to=webfilter.redirect_on_request_exception,
|
|
114
|
+
)
|
|
115
|
+
logger.info(f"Not halting on request exception '{e}'."
|
|
116
|
+
f"{webfilter.event} webhook filter triggered to {webfilter.webhook_url}")
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if 400 <= response.status_code <= 499 and webfilter.halt_on_4xx and exception:
|
|
120
|
+
logger.info(f"Request to {webfilter.webhook_url} after webhook event {webfilter.event} returned status "
|
|
121
|
+
f"code {response.status_code} ({response.reason}). Redirecting to {webfilter.redirect_on_4xx}")
|
|
122
|
+
_raise_filter_exception(
|
|
123
|
+
exception,
|
|
124
|
+
message=f"Request to {webfilter.webhook_url} after webhook event {webfilter.event} returned status "
|
|
125
|
+
f"code {response.status_code} ({response.reason})",
|
|
126
|
+
redirect_to=webfilter.redirect_on_4xx,
|
|
127
|
+
status_code=response.status_code
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if 500 <= response.status_code <= 599 and webfilter.halt_on_5xx and exception:
|
|
131
|
+
logger.info(f"Request to {webfilter.webhook_url} after webhook event {webfilter.event} returned status "
|
|
132
|
+
f"code {response.status_code} ({response.reason}). Redirecting to {webfilter.redirect_on_5xx}")
|
|
133
|
+
_raise_filter_exception(
|
|
134
|
+
exception,
|
|
135
|
+
message=f"Request to {webfilter.webhook_url} after webhook event {webfilter.event} returned status "
|
|
136
|
+
f"code {response.status_code} ({response.reason})",
|
|
137
|
+
redirect_to=webfilter.redirect_on_5xx,
|
|
138
|
+
status_code=response.status_code
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
logger.info(f"Request to {webfilter.webhook_url} after webhook event {webfilter.event} returned status code "
|
|
142
|
+
f"{response.status_code} ({response.reason}).")
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
response = json.loads(response.text)
|
|
146
|
+
except json.decoder.JSONDecodeError as e:
|
|
147
|
+
logger.warning("Non-JSON response for webfilter %s: %s", webfilter.event, type(e).__name__)
|
|
148
|
+
response = {}
|
|
149
|
+
|
|
150
|
+
if not isinstance(response, dict):
|
|
151
|
+
logger.warning("Expected a JSON object for webfilter %s", webfilter.event)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if not webfilter.disable_filtering:
|
|
155
|
+
# We need to accumulate the responses in case there are many webhook filters
|
|
156
|
+
if 'data' in response:
|
|
157
|
+
r = response.get('data')
|
|
158
|
+
if isinstance(r, dict):
|
|
159
|
+
response_data.update(r)
|
|
160
|
+
else:
|
|
161
|
+
logger.error("Web filter %s returned a non-dict 'data' key", webfilter.event)
|
|
162
|
+
else:
|
|
163
|
+
logger.warning(f"Web filter {webfilter.event} enabled but "
|
|
164
|
+
f"call to {webfilter.webhook_url} returned no 'data' key.")
|
|
165
|
+
|
|
166
|
+
if not webfilter.disable_halt:
|
|
167
|
+
# We accumulate the exceptions requested when enabled. Only one will work
|
|
168
|
+
if 'exception' in response:
|
|
169
|
+
r = response.get('exception')
|
|
170
|
+
if isinstance(r, dict):
|
|
171
|
+
response_exceptions.update(r)
|
|
172
|
+
else:
|
|
173
|
+
logger.error("Web filter %s returned a non-dict 'exception' key", webfilter.event)
|
|
174
|
+
else:
|
|
175
|
+
logger.warning(f"Web filter {webfilter.event} exceptions enabled but "
|
|
176
|
+
f"call to {webfilter.webhook_url} returned no 'exception' key.")
|
|
177
|
+
|
|
178
|
+
return response_data, response_exceptions
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _raise_filter_exception(exception_class, **kwargs):
|
|
182
|
+
"""Pass only arguments accepted by the upstream exception constructor."""
|
|
183
|
+
parameters = signature(exception_class).parameters
|
|
184
|
+
if not any(parameter.kind == Parameter.VAR_KEYWORD for parameter in parameters.values()):
|
|
185
|
+
kwargs = {key: value for key, value in kwargs.items() if key in parameters}
|
|
186
|
+
raise exception_class(**kwargs)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mutable_model_fields(instance):
|
|
190
|
+
"""Identify model fields that a remote response may edit."""
|
|
191
|
+
protected = SENSITIVE_FIELDS | {'is_staff', 'is_superuser', 'is_active', 'last_login', 'date_joined'}
|
|
192
|
+
return {
|
|
193
|
+
field.name for field in instance._meta.concrete_fields
|
|
194
|
+
if not field.primary_key and not field.is_relation and field.name not in protected
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def update_model(instance, data):
|
|
199
|
+
"""Update concrete model fields, excluding identity, relations and credentials."""
|
|
200
|
+
if isinstance(data, dict):
|
|
201
|
+
allowed = _mutable_model_fields(instance)
|
|
202
|
+
changed = []
|
|
203
|
+
for key, value in data.items():
|
|
204
|
+
if key in allowed:
|
|
205
|
+
if isinstance(getattr(instance, key), datetime):
|
|
206
|
+
# Handle date time data
|
|
207
|
+
setattr(instance, key, datetime.fromisoformat(value))
|
|
208
|
+
else:
|
|
209
|
+
setattr(instance, key, value)
|
|
210
|
+
changed.append(key)
|
|
211
|
+
if changed:
|
|
212
|
+
instance.save(update_fields=changed)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def update_query_dict(query_dict, data):
|
|
216
|
+
"""
|
|
217
|
+
Update a QueryDict object with dict with data.
|
|
218
|
+
|
|
219
|
+
We need a special function to update a query dict because the update method will append the new data
|
|
220
|
+
instead of replacing it.
|
|
221
|
+
See https://docs.djangoproject.com/en/4.2/ref/request-response/#django.http.QueryDict.update.
|
|
222
|
+
"""
|
|
223
|
+
result = query_dict.copy()
|
|
224
|
+
|
|
225
|
+
if isinstance(data, dict):
|
|
226
|
+
for key, value in data.items():
|
|
227
|
+
if key.lower() in SENSITIVE_FIELDS:
|
|
228
|
+
continue
|
|
229
|
+
if isinstance(query_dict.get(key), datetime):
|
|
230
|
+
# Handle date time data
|
|
231
|
+
result[key] = datetime.fromisoformat(value)
|
|
232
|
+
elif isinstance(value, list) and hasattr(result, 'setlist'):
|
|
233
|
+
result.setlist(key, value)
|
|
234
|
+
else:
|
|
235
|
+
result[key] = value
|
|
236
|
+
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def update_object(o, data):
|
|
241
|
+
"""
|
|
242
|
+
Update a generic object with dict with data.
|
|
243
|
+
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
if isinstance(data, dict):
|
|
247
|
+
for key, value in data.items():
|
|
248
|
+
if key.lower() in SENSITIVE_FIELDS or key in {'id', 'pk', 'is_staff', 'is_superuser'}:
|
|
249
|
+
continue
|
|
250
|
+
if isinstance(o, models.Model) and key not in _mutable_model_fields(o):
|
|
251
|
+
continue
|
|
252
|
+
if isinstance(getattr(o, key), datetime):
|
|
253
|
+
# Handle date time data
|
|
254
|
+
setattr(o, key, datetime.fromisoformat(value))
|
|
255
|
+
elif isinstance(getattr(o, key), bool):
|
|
256
|
+
if isinstance(value, bool):
|
|
257
|
+
setattr(o, key, value)
|
|
258
|
+
elif isinstance(value, str) and value.lower() in {'true', 'false'}:
|
|
259
|
+
setattr(o, key, value.lower() == 'true')
|
|
260
|
+
else:
|
|
261
|
+
logger.error("Cannot update boolean field %s on %s: expected a boolean", key, type(o).__name__)
|
|
262
|
+
continue
|
|
263
|
+
else:
|
|
264
|
+
setattr(o, key, value)
|
|
265
|
+
except AttributeError as e:
|
|
266
|
+
logger.error("Cannot update object of type %s: %s", type(o).__name__, type(e).__name__)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _check_for_exception(exceptions, exception_class):
|
|
270
|
+
"""
|
|
271
|
+
Check if an exception configuration exists and then raises the exception.
|
|
272
|
+
"""
|
|
273
|
+
if exception_class and exception_class.__name__ in exceptions:
|
|
274
|
+
exception_settings = exceptions.get(exception_class.__name__)
|
|
275
|
+
|
|
276
|
+
# In the special case of CertificateRenderStarted.RenderCustomResponse the exception must include a
|
|
277
|
+
# response object
|
|
278
|
+
if exception_class in [
|
|
279
|
+
AccountSettingsRenderStarted.RenderCustomResponse,
|
|
280
|
+
CertificateRenderStarted.RenderCustomResponse,
|
|
281
|
+
CourseAboutRenderStarted.RenderCustomResponse,
|
|
282
|
+
DashboardRenderStarted.RenderCustomResponse,
|
|
283
|
+
RenderXBlockStarted.RenderCustomResponse,
|
|
284
|
+
InstructorDashboardRenderStarted.RenderCustomResponse,
|
|
285
|
+
]:
|
|
286
|
+
raise exception_class(
|
|
287
|
+
message="Render Custom Response",
|
|
288
|
+
response=HttpResponse(**exception_settings))
|
|
289
|
+
|
|
290
|
+
if isinstance(exception_settings, str):
|
|
291
|
+
raise exception_class(exception_settings)
|
|
292
|
+
if isinstance(exception_settings, dict):
|
|
293
|
+
exception_settings = exception_settings.copy()
|
|
294
|
+
if 'message' not in exception_settings:
|
|
295
|
+
exception_settings['message'] = ''
|
|
296
|
+
raise exception_class(**exception_settings)
|
|
297
|
+
raise exception_class("Reason not specified")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class StudentLoginRequestedWebFilter(PipelineStep):
|
|
301
|
+
"""
|
|
302
|
+
Process StudentLoginRequested filter.
|
|
303
|
+
|
|
304
|
+
This filter is triggered when a user attempts to log in.
|
|
305
|
+
|
|
306
|
+
I will POST a json to the webhook url with the user and profile information
|
|
307
|
+
|
|
308
|
+
EXAMPLE::
|
|
309
|
+
|
|
310
|
+
{
|
|
311
|
+
"user": {
|
|
312
|
+
"id": 4,
|
|
313
|
+
"last_login": "2023-06-07 20:26:39.890251+00:00",
|
|
314
|
+
"is_superuser": true,
|
|
315
|
+
"username": "myuser",
|
|
316
|
+
"first_name": "",
|
|
317
|
+
"last_name": "",
|
|
318
|
+
"email": "myemail@aulasneo.com",
|
|
319
|
+
"is_staff": true,
|
|
320
|
+
"is_active": true,
|
|
321
|
+
"date_joined": "2023-01-26 16:22:57.939766+00:00"
|
|
322
|
+
},
|
|
323
|
+
"profile": {
|
|
324
|
+
"id": 2,
|
|
325
|
+
"user_id": 4,
|
|
326
|
+
"name": "Andrés González",
|
|
327
|
+
"meta": "",
|
|
328
|
+
"courseware": "course.xml",
|
|
329
|
+
"language": "",
|
|
330
|
+
"location": "",
|
|
331
|
+
"year_of_birth": null,
|
|
332
|
+
"gender": null,
|
|
333
|
+
"level_of_education": null,
|
|
334
|
+
"mailing_address": null,
|
|
335
|
+
"city": null,
|
|
336
|
+
"country": null,
|
|
337
|
+
"state": null,
|
|
338
|
+
"goals": null,
|
|
339
|
+
"bio": null,
|
|
340
|
+
"profile_image_uploaded_at": null,
|
|
341
|
+
"phone_number": null
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
The webhook processor can return a json with two objects: data and exception.
|
|
346
|
+
|
|
347
|
+
EXAMPLE::
|
|
348
|
+
|
|
349
|
+
{
|
|
350
|
+
"data": {
|
|
351
|
+
"user": {
|
|
352
|
+
<key>:<value>,...
|
|
353
|
+
},
|
|
354
|
+
"profile": {
|
|
355
|
+
<key>:<value>,...
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
"exception": {
|
|
359
|
+
"PreventLogin": {
|
|
360
|
+
"message":<message>,
|
|
361
|
+
"redirect_to": <redirect URL>,
|
|
362
|
+
"error_code": <error code>,
|
|
363
|
+
"context": {
|
|
364
|
+
<context key>: <context value>,...
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
"user" and "profile" keys are optionals, as well as the keys inside each.
|
|
371
|
+
|
|
372
|
+
"PreventLogin" can be a json as in the example or a string value with the message text,
|
|
373
|
+
leaving the other keys empty.
|
|
374
|
+
|
|
375
|
+
EXAMPLE::
|
|
376
|
+
|
|
377
|
+
...
|
|
378
|
+
"exception": {
|
|
379
|
+
"PreventLogin": <message>
|
|
380
|
+
}
|
|
381
|
+
...
|
|
382
|
+
|
|
383
|
+
PreventLogin exception accepts message, redirect_to, error_code and context.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def run_filter(self, user): # pylint: disable=arguments-differ
|
|
387
|
+
"""Execute the filter."""
|
|
388
|
+
event = "StudentLoginRequested"
|
|
389
|
+
|
|
390
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
391
|
+
|
|
392
|
+
if webfilters:
|
|
393
|
+
logger.info(f"Webfilter for {event} event for user {user}")
|
|
394
|
+
|
|
395
|
+
if user:
|
|
396
|
+
# If the log in attempt is unsuccessfull, the user object will be None
|
|
397
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
398
|
+
data={
|
|
399
|
+
"user": user,
|
|
400
|
+
"profile": user.profile,
|
|
401
|
+
},
|
|
402
|
+
exception=StudentLoginRequested.PreventLogin)
|
|
403
|
+
|
|
404
|
+
_check_for_exception(exceptions, StudentLoginRequested.PreventLogin)
|
|
405
|
+
update_model(user, content.get('user'))
|
|
406
|
+
update_model(user.profile, content.get('profile'))
|
|
407
|
+
else:
|
|
408
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
409
|
+
data={},
|
|
410
|
+
exception=StudentLoginRequested.PreventLogin)
|
|
411
|
+
|
|
412
|
+
_check_for_exception(exceptions, StudentLoginRequested.PreventLogin)
|
|
413
|
+
|
|
414
|
+
return {"user": user}
|
|
415
|
+
|
|
416
|
+
return {}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class StudentRegistrationRequestedWebFilter(PipelineStep):
|
|
420
|
+
"""
|
|
421
|
+
Process StudentRegistrationRequested filter.
|
|
422
|
+
|
|
423
|
+
This filter is triggered when a new user submits the registration form.
|
|
424
|
+
|
|
425
|
+
It will POST a json to the webhook url with the user and profile information.
|
|
426
|
+
|
|
427
|
+
EXAMPLE::
|
|
428
|
+
|
|
429
|
+
{
|
|
430
|
+
"next": "/",
|
|
431
|
+
"email": "test@aulasneo.com",
|
|
432
|
+
"name": "Full Name",
|
|
433
|
+
"username": "public_name",
|
|
434
|
+
"level_of_education": "",
|
|
435
|
+
"gender": "",
|
|
436
|
+
"year_of_birth": "",
|
|
437
|
+
"mailing_address": "",
|
|
438
|
+
"goals": "",
|
|
439
|
+
"terms_of_service": "true"
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
The webhook processor can return a json with two objects: data and exception.
|
|
443
|
+
|
|
444
|
+
EXAMPLE::
|
|
445
|
+
|
|
446
|
+
{
|
|
447
|
+
"data": {
|
|
448
|
+
"form_data": {
|
|
449
|
+
<key>:<value>,...
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
"exception": {
|
|
453
|
+
"PreventRegistration": {
|
|
454
|
+
"message":<message>,
|
|
455
|
+
"redirect_to": <redirect URL>,
|
|
456
|
+
"error_code": <error code>,
|
|
457
|
+
"context": {
|
|
458
|
+
<context key>: <context value>,...
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
"user" and "profile" keys are optionals, as well as the keys inside each.
|
|
465
|
+
|
|
466
|
+
"PreventRegistration" can be a json as in the example or a string value with the message text,
|
|
467
|
+
leaving the other keys empty.
|
|
468
|
+
|
|
469
|
+
EXAMPLE::
|
|
470
|
+
|
|
471
|
+
...
|
|
472
|
+
"exception": {
|
|
473
|
+
"PreventRegistration": <message>
|
|
474
|
+
}
|
|
475
|
+
...
|
|
476
|
+
|
|
477
|
+
PreventRegistration accepts message and status_code. If status_code==200 then the registration is accepted.
|
|
478
|
+
|
|
479
|
+
Notes:
|
|
480
|
+
- level_of_education must be one of
|
|
481
|
+
p: Doctorate,
|
|
482
|
+
m: Master's or professional degree,
|
|
483
|
+
b: Bachelor's degree,
|
|
484
|
+
a: Associate degree,
|
|
485
|
+
hs: Secondary/high school,
|
|
486
|
+
jhs: Junior secondary/junior high/middle school,
|
|
487
|
+
el: Elementary/primary school,
|
|
488
|
+
none: No formal education,
|
|
489
|
+
other: Other education
|
|
490
|
+
- gender must be one of:
|
|
491
|
+
m: male
|
|
492
|
+
f: female
|
|
493
|
+
o: other
|
|
494
|
+
- terms_of_service must be true or false
|
|
495
|
+
|
|
496
|
+
Due to privacy control, the password cannot be seen nor modified.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
def run_filter(self, form_data): # pylint: disable=arguments-differ
|
|
500
|
+
"""Execute the filter."""
|
|
501
|
+
event = "StudentRegistrationRequested"
|
|
502
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
503
|
+
|
|
504
|
+
if webfilters:
|
|
505
|
+
from common.djangoapps.student.models import ( # pylint: disable=import-error,import-outside-toplevel
|
|
506
|
+
UserProfile,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
logger.info("Webfilter for %s", event)
|
|
510
|
+
|
|
511
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
512
|
+
data=redact_sensitive_fields(dict(form_data.items())),
|
|
513
|
+
exception=StudentRegistrationRequested.PreventRegistration)
|
|
514
|
+
|
|
515
|
+
form_data_response = content.get('form_data') or {}
|
|
516
|
+
|
|
517
|
+
# Validate form data response
|
|
518
|
+
if 'level_of_education' in form_data_response \
|
|
519
|
+
and form_data_response.get('level_of_education') not in \
|
|
520
|
+
[choice[0] for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES]:
|
|
521
|
+
raise ValueError(f"'{form_data_response.get('level_of_education')}' is not a valid level of education."
|
|
522
|
+
f"Valid options are: " +
|
|
523
|
+
", ".join([f"{c[0]}: {c[1]}" for c in UserProfile.LEVEL_OF_EDUCATION_CHOICES]))
|
|
524
|
+
|
|
525
|
+
if 'gender' in form_data_response \
|
|
526
|
+
and form_data_response.get('gender') not in \
|
|
527
|
+
[choice[0] for choice in UserProfile.GENDER_CHOICES]:
|
|
528
|
+
raise ValueError(f"'{form_data_response.get('gender')}' is not a valid gender."
|
|
529
|
+
f"Valid options are: " +
|
|
530
|
+
", ".join([f"{c[0]}: {c[1]}" for c in UserProfile.GENDER_CHOICES]))
|
|
531
|
+
|
|
532
|
+
if 'terms_of_service' in form_data_response \
|
|
533
|
+
and str(form_data_response.get('terms_of_service')).lower() not in ["true", "false"]:
|
|
534
|
+
raise ValueError(f"'{form_data_response.get('terms_of_service')}' is not a boolean value."
|
|
535
|
+
f"Valid options are: " +
|
|
536
|
+
", ".join(["true", "false"]))
|
|
537
|
+
|
|
538
|
+
updated_form_data = update_query_dict(form_data, form_data_response)
|
|
539
|
+
|
|
540
|
+
_check_for_exception(exceptions, StudentRegistrationRequested.PreventRegistration)
|
|
541
|
+
|
|
542
|
+
return {"form_data": updated_form_data}
|
|
543
|
+
|
|
544
|
+
return {}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class AccountSettingsRenderStartedWebFilter(PipelineStep):
|
|
548
|
+
"""
|
|
549
|
+
Process AccountSettingsRenderStarted filter.
|
|
550
|
+
|
|
551
|
+
This filter is triggered before the legacy account settings page is rendered.
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
def run_filter(self, context, template_name): # pylint: disable=arguments-differ
|
|
555
|
+
"""
|
|
556
|
+
Execute a filter with the signature specified.
|
|
557
|
+
|
|
558
|
+
Arguments:
|
|
559
|
+
context (dict): context dictionary for the account settings template.
|
|
560
|
+
template_name (str): template name to be rendered by the account settings page.
|
|
561
|
+
"""
|
|
562
|
+
event = "AccountSettingsRenderStarted"
|
|
563
|
+
|
|
564
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
565
|
+
|
|
566
|
+
if webfilters:
|
|
567
|
+
logger.info(f"Webfilter for {event} event.")
|
|
568
|
+
|
|
569
|
+
data = {
|
|
570
|
+
"context": context,
|
|
571
|
+
"template_name": template_name,
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
content, exceptions = _process_filter(
|
|
575
|
+
webfilters=webfilters,
|
|
576
|
+
data=data,
|
|
577
|
+
exception=AccountSettingsRenderStarted.RedirectToPage,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
context.update(content.get('context', {}))
|
|
581
|
+
|
|
582
|
+
_check_for_exception(exceptions, AccountSettingsRenderStarted.RedirectToPage)
|
|
583
|
+
_check_for_exception(exceptions, AccountSettingsRenderStarted.RenderCustomResponse)
|
|
584
|
+
_check_for_exception(exceptions, AccountSettingsRenderStarted.RenderInvalidAccountSettings)
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
"context": context,
|
|
588
|
+
"template_name": content.get('template_name') or template_name,
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class CourseEnrollmentStartedWebFilter(PipelineStep):
|
|
595
|
+
"""
|
|
596
|
+
Process CourseEnrollmentStarted filter.
|
|
597
|
+
|
|
598
|
+
This filter is triggered when a user is enrolled in a course.
|
|
599
|
+
|
|
600
|
+
It will POST a json to the webhook url with information about the user, the profile, the course id and mode.
|
|
601
|
+
|
|
602
|
+
EXAMPLE::
|
|
603
|
+
|
|
604
|
+
{
|
|
605
|
+
"user": {
|
|
606
|
+
"id": 4,
|
|
607
|
+
"last_login": "2023-06-13 15:04:10.629206+00:00",
|
|
608
|
+
"is_superuser": true,
|
|
609
|
+
"username": "andres",
|
|
610
|
+
"first_name": "",
|
|
611
|
+
"last_name": "",
|
|
612
|
+
"email": "andres@aulasneo.com",
|
|
613
|
+
"is_staff": true,
|
|
614
|
+
"is_active": true,
|
|
615
|
+
"date_joined": "2023-01-26 16:22:57.939766+00:00"
|
|
616
|
+
},
|
|
617
|
+
"profile": {
|
|
618
|
+
"id": 2,
|
|
619
|
+
"user_id": 4,
|
|
620
|
+
"name": "Andrés González",
|
|
621
|
+
"meta": "",
|
|
622
|
+
"courseware": "course.xml",
|
|
623
|
+
"language": "",
|
|
624
|
+
"location": "",
|
|
625
|
+
"year_of_birth": null,
|
|
626
|
+
"gender": null,
|
|
627
|
+
"level_of_education": null,
|
|
628
|
+
"mailing_address": null,
|
|
629
|
+
"city": null,
|
|
630
|
+
"country": null,
|
|
631
|
+
"state": null,
|
|
632
|
+
"goals": null,
|
|
633
|
+
"bio": null,
|
|
634
|
+
"profile_image_uploaded_at": null,
|
|
635
|
+
"phone_number": null
|
|
636
|
+
},
|
|
637
|
+
"course_key": "course-v1:test+test+test",
|
|
638
|
+
"mode": "honor",
|
|
639
|
+
"event_metadata": {
|
|
640
|
+
"event_type": "CourseEnrollmentStarted",
|
|
641
|
+
"time": "2023-06-13 20:59:26.093379"
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
The webhook processor can return a json with any data to modify.
|
|
646
|
+
|
|
647
|
+
EXAMPLE::
|
|
648
|
+
|
|
649
|
+
{
|
|
650
|
+
"data": {
|
|
651
|
+
"mode": "audit"
|
|
652
|
+
},
|
|
653
|
+
"exception": {
|
|
654
|
+
"PreventEnrollment": "Enrollment not allowed"
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
All keys are optional, as well as the keys inside each.
|
|
659
|
+
|
|
660
|
+
"PreventEnrollment" can have a message to be logged.
|
|
661
|
+
|
|
662
|
+
EXAMPLE::
|
|
663
|
+
|
|
664
|
+
...
|
|
665
|
+
"exception": {
|
|
666
|
+
"PreventEnrollment": <message>
|
|
667
|
+
}
|
|
668
|
+
...
|
|
669
|
+
|
|
670
|
+
PreventEnrollment accepts a message.
|
|
671
|
+
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
def run_filter(self, user, course_key, mode): # pylint: disable=arguments-differ
|
|
675
|
+
"""
|
|
676
|
+
Execute a filter with the signature specified.
|
|
677
|
+
|
|
678
|
+
Arguments:
|
|
679
|
+
user (User): is a Django User object.
|
|
680
|
+
course_key (CourseKey): course key associated with the enrollment.
|
|
681
|
+
mode (str): is a string specifying what kind of enrollment.
|
|
682
|
+
|
|
683
|
+
"""
|
|
684
|
+
event = "CourseEnrollmentStarted"
|
|
685
|
+
|
|
686
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
687
|
+
|
|
688
|
+
if webfilters:
|
|
689
|
+
logger.info(f"Webfilter for {event} event. User: {user}, course: {course_key}, mode: {mode}.")
|
|
690
|
+
|
|
691
|
+
data = {
|
|
692
|
+
'user': user,
|
|
693
|
+
'profile': user.profile,
|
|
694
|
+
'course_key': course_key,
|
|
695
|
+
'mode': mode,
|
|
696
|
+
}
|
|
697
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
698
|
+
data=data,
|
|
699
|
+
exception=CourseEnrollmentStarted.PreventEnrollment)
|
|
700
|
+
|
|
701
|
+
_check_for_exception(exceptions, CourseEnrollmentStarted.PreventEnrollment)
|
|
702
|
+
update_model(user, content.get('user'))
|
|
703
|
+
update_model(user.profile, content.get('profile'))
|
|
704
|
+
|
|
705
|
+
if 'course_key' in content:
|
|
706
|
+
course_key = CourseKey.from_string(content.get('course_key'))
|
|
707
|
+
|
|
708
|
+
if 'mode' in content:
|
|
709
|
+
mode = content.get('mode')
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
"user": user,
|
|
713
|
+
"course_key": course_key,
|
|
714
|
+
"mode": mode,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return {}
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
class CourseUnenrollmentStartedWebFilter(PipelineStep):
|
|
721
|
+
"""
|
|
722
|
+
Process CourseUnenrollmentStarted filter.
|
|
723
|
+
|
|
724
|
+
This filter is triggered when a user is unenrolled from a course.
|
|
725
|
+
|
|
726
|
+
It will POST a JSON payload to the webhook URL with the enrollment object.
|
|
727
|
+
|
|
728
|
+
**Example Request Payload:**
|
|
729
|
+
|
|
730
|
+
.. code-block:: json
|
|
731
|
+
|
|
732
|
+
{
|
|
733
|
+
"user": {
|
|
734
|
+
"id": 4,
|
|
735
|
+
"last_login": "2023-06-13 15:04:10.629206+00:00",
|
|
736
|
+
"is_superuser": true,
|
|
737
|
+
"username": "andres",
|
|
738
|
+
"first_name": "hola",
|
|
739
|
+
"last_name": "",
|
|
740
|
+
"email": "andres@aulasneo.com",
|
|
741
|
+
"is_staff": true,
|
|
742
|
+
"is_active": true,
|
|
743
|
+
"date_joined": "2023-01-26 16:22:57.939766+00:00"
|
|
744
|
+
},
|
|
745
|
+
"profile": {
|
|
746
|
+
"id": 2,
|
|
747
|
+
"user_id": 4,
|
|
748
|
+
"name": "Andrés González",
|
|
749
|
+
"meta": "",
|
|
750
|
+
"courseware": "course.xml",
|
|
751
|
+
"language": "",
|
|
752
|
+
"location": "",
|
|
753
|
+
"year_of_birth": null,
|
|
754
|
+
"gender": null,
|
|
755
|
+
"level_of_education": null,
|
|
756
|
+
"mailing_address": null,
|
|
757
|
+
"city": null,
|
|
758
|
+
"country": null,
|
|
759
|
+
"state": null,
|
|
760
|
+
"goals": null,
|
|
761
|
+
"bio": null,
|
|
762
|
+
"profile_image_uploaded_at": null,
|
|
763
|
+
"phone_number": null
|
|
764
|
+
},
|
|
765
|
+
"course_key": "course-v1:test+test+test",
|
|
766
|
+
"mode": "honor",
|
|
767
|
+
"event_metadata": {
|
|
768
|
+
"event_type": "CourseEnrollmentStarted",
|
|
769
|
+
"time": "2023-06-13 21:02:50.375064"
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
The webhook processor can return a JSON response with two top-level objects: ``data`` and ``exception``.
|
|
774
|
+
|
|
775
|
+
**Example Response Payload:**
|
|
776
|
+
|
|
777
|
+
.. code-block:: json
|
|
778
|
+
|
|
779
|
+
{
|
|
780
|
+
"data": {
|
|
781
|
+
"user": {
|
|
782
|
+
"<key>": "<value>"
|
|
783
|
+
},
|
|
784
|
+
"profile": {
|
|
785
|
+
"<key>": "<value>"
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
"exception": {
|
|
789
|
+
"PreventUnenrollment": "<message>"
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
The ``user`` and ``profile`` keys are optional, as well as the fields inside each.
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
def run_filter(self, enrollment): # pylint: disable=arguments-differ
|
|
797
|
+
"""
|
|
798
|
+
Execute a filter with the signature specified.
|
|
799
|
+
|
|
800
|
+
:param enrollment: The enrollment object representing the user and their course registration.
|
|
801
|
+
:type enrollment: User
|
|
802
|
+
"""
|
|
803
|
+
event = "CourseUnenrollmentStarted"
|
|
804
|
+
|
|
805
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
806
|
+
|
|
807
|
+
if webfilters:
|
|
808
|
+
logger.info(f"Webfilter for {event} event. Enrollment: {enrollment}")
|
|
809
|
+
|
|
810
|
+
user = enrollment.user
|
|
811
|
+
|
|
812
|
+
data = {
|
|
813
|
+
'user': user,
|
|
814
|
+
'profile': user.profile,
|
|
815
|
+
'enrollment': enrollment,
|
|
816
|
+
}
|
|
817
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
818
|
+
data=data,
|
|
819
|
+
exception=CourseUnenrollmentStarted.PreventUnenrollment)
|
|
820
|
+
|
|
821
|
+
_check_for_exception(exceptions, CourseUnenrollmentStarted.PreventUnenrollment)
|
|
822
|
+
update_model(user, content.get('user'))
|
|
823
|
+
update_model(user.profile, content.get('profile'))
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
"enrollment": enrollment,
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return {}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
class CertificateCreationRequestedWebFilter(PipelineStep):
|
|
833
|
+
"""
|
|
834
|
+
Process CertificateCreationRequested filter.
|
|
835
|
+
|
|
836
|
+
This filter is triggered when a certificate creation is requested.
|
|
837
|
+
|
|
838
|
+
It will POST a JSON payload to the webhook URL with the enrollment object.
|
|
839
|
+
|
|
840
|
+
**Example Request Payload:**
|
|
841
|
+
|
|
842
|
+
.. code-block:: json
|
|
843
|
+
|
|
844
|
+
{
|
|
845
|
+
"user": {
|
|
846
|
+
"id": 17,
|
|
847
|
+
"last_login": "2023-06-14 16:11:08.341205+00:00",
|
|
848
|
+
"is_superuser": false,
|
|
849
|
+
"username": "test1",
|
|
850
|
+
"first_name": "",
|
|
851
|
+
"last_name": "",
|
|
852
|
+
"email": "test1@aulasneo.com",
|
|
853
|
+
"is_staff": false,
|
|
854
|
+
"is_active": true,
|
|
855
|
+
"date_joined": "2023-06-12 20:29:37.756206+00:00"
|
|
856
|
+
},
|
|
857
|
+
"profile": {
|
|
858
|
+
"id": 13,
|
|
859
|
+
"user_id": 17,
|
|
860
|
+
"name": "test1",
|
|
861
|
+
"meta": "",
|
|
862
|
+
"courseware": "course.xml",
|
|
863
|
+
"language": "",
|
|
864
|
+
"location": "",
|
|
865
|
+
"year_of_birth": null,
|
|
866
|
+
"gender": "",
|
|
867
|
+
"level_of_education": "",
|
|
868
|
+
"mailing_address": "",
|
|
869
|
+
"city": "",
|
|
870
|
+
"country": "",
|
|
871
|
+
"state": null,
|
|
872
|
+
"goals": "",
|
|
873
|
+
"bio": null,
|
|
874
|
+
"profile_image_uploaded_at": null,
|
|
875
|
+
"phone_number": null
|
|
876
|
+
},
|
|
877
|
+
"course_key": "course-v1:test+test+test",
|
|
878
|
+
"mode": "honor",
|
|
879
|
+
"status": null,
|
|
880
|
+
"grade": {
|
|
881
|
+
"user": "test1",
|
|
882
|
+
"course_data": "Course: course_key: course-v1:test+test+test",
|
|
883
|
+
"percent": 1,
|
|
884
|
+
"passed": true,
|
|
885
|
+
"letter_grade": "Pass",
|
|
886
|
+
"force_update_subsections": false,
|
|
887
|
+
"_subsection_grade_factory": "<lms.djangoapps.grades.subsection_grade_factory.SubsectionGradeFactory >"
|
|
888
|
+
},
|
|
889
|
+
"generation_mode": "self",
|
|
890
|
+
"event_metadata": {
|
|
891
|
+
"event_type": "CertificateCreationRequested",
|
|
892
|
+
"time": "2023-06-14 16:28:23.266529"
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
The webhook processor can return a JSON response with two top-level objects: ``data`` and ``exception``.
|
|
897
|
+
|
|
898
|
+
**Example Response Payload:**
|
|
899
|
+
|
|
900
|
+
.. code-block:: json
|
|
901
|
+
|
|
902
|
+
{
|
|
903
|
+
"data": {
|
|
904
|
+
"user": {
|
|
905
|
+
"<key>": "<value>"
|
|
906
|
+
},
|
|
907
|
+
"profile": {
|
|
908
|
+
"<key>": "<value>"
|
|
909
|
+
},
|
|
910
|
+
"course_key": "<course key>",
|
|
911
|
+
"mode": "<mode>",
|
|
912
|
+
"status": "<status>",
|
|
913
|
+
"grade": {
|
|
914
|
+
"percent": 0.95,
|
|
915
|
+
"passed": true,
|
|
916
|
+
"letter_grade": "Pass",
|
|
917
|
+
"force_update_subsections": false
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
"exception": {
|
|
921
|
+
"PreventCertificateCreation": "<message>"
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
All ``data`` keys are optional, including any nested fields.
|
|
926
|
+
|
|
927
|
+
.. note::
|
|
928
|
+
|
|
929
|
+
Changes in the grade values do not affect the certificate and do not modify the user's actual grade.
|
|
930
|
+
|
|
931
|
+
"""
|
|
932
|
+
|
|
933
|
+
def run_filter(self, **data):
|
|
934
|
+
"""
|
|
935
|
+
Execute a filter with the signature specified.
|
|
936
|
+
|
|
937
|
+
Arguments:
|
|
938
|
+
|
|
939
|
+
- data:
|
|
940
|
+
- user (User): is a Django User object.
|
|
941
|
+
- course_key (CourseKey): course key associated with the certificate.
|
|
942
|
+
- mode (str): mode of the certificate.
|
|
943
|
+
- status (str): status of the certificate.
|
|
944
|
+
- grade (CourseGrade): user's grade in this course run.
|
|
945
|
+
- generation_mode (str): Options are "self" (implying the user generated the cert themself)
|
|
946
|
+
and "batch" for everything else.
|
|
947
|
+
|
|
948
|
+
"""
|
|
949
|
+
event = "CertificateCreationRequested"
|
|
950
|
+
|
|
951
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
952
|
+
|
|
953
|
+
if webfilters:
|
|
954
|
+
user = data.get('user')
|
|
955
|
+
course_key = data.get('course_key')
|
|
956
|
+
grade = data.get('grade')
|
|
957
|
+
status = data.get('status')
|
|
958
|
+
|
|
959
|
+
logger.info(f"Webfilter for {event} event. User: {user}, course: {course_key}, status: {status}")
|
|
960
|
+
|
|
961
|
+
data["profile"] = user.profile
|
|
962
|
+
data["completion_summary"] = get_course_blocks_completion_summary(course_key, user)
|
|
963
|
+
|
|
964
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
965
|
+
data=object_serializer(data),
|
|
966
|
+
exception=CertificateCreationRequested.PreventCertificateCreation)
|
|
967
|
+
|
|
968
|
+
_check_for_exception(exceptions, CertificateCreationRequested.PreventCertificateCreation)
|
|
969
|
+
update_model(user, content.get('user'))
|
|
970
|
+
update_model(user.profile, content.get('profile'))
|
|
971
|
+
|
|
972
|
+
if 'course_key' in content:
|
|
973
|
+
course_key = CourseKey.from_string(content.get('course_key'))
|
|
974
|
+
|
|
975
|
+
update_object(grade, content.get('grade'))
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
"user": user,
|
|
979
|
+
"course_key": course_key,
|
|
980
|
+
"mode": content.get('mode', data.get('mode')),
|
|
981
|
+
"status": content.get('status', data.get('status')),
|
|
982
|
+
"grade": grade,
|
|
983
|
+
"generation_mode": content.get('generation_mode', data.get('generation_mode')),
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return {}
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
class CertificateRenderStartedWebFilter(PipelineStep):
|
|
990
|
+
"""
|
|
991
|
+
Process CertificateRenderStarted filter.
|
|
992
|
+
|
|
993
|
+
This filter is triggered when a certificate is about to be rendered.
|
|
994
|
+
|
|
995
|
+
It will POST a JSON payload to the webhook URL containing the enrollment object.
|
|
996
|
+
|
|
997
|
+
**Example Request Payload:**
|
|
998
|
+
|
|
999
|
+
.. code-block:: json
|
|
1000
|
+
|
|
1001
|
+
{
|
|
1002
|
+
"context": {
|
|
1003
|
+
"user_language": "en",
|
|
1004
|
+
"platform_name": "Your Platform Name Here",
|
|
1005
|
+
"course_id": "course-v1:test+test+test",
|
|
1006
|
+
"accomplishment_class_append": "accomplishment-certificate",
|
|
1007
|
+
"company_about_url": "/about",
|
|
1008
|
+
"company_privacy_url": "/privacy",
|
|
1009
|
+
"company_tos_url": "/tos_and_honor",
|
|
1010
|
+
"company_verified_certificate_url": "http://www.example.com/verified-certificate",
|
|
1011
|
+
"logo_src": "/media/certificate_template_assets/2/logo.png",
|
|
1012
|
+
"logo_url": "http://local.overhang.io:8000",
|
|
1013
|
+
"copyright_text": "© 2023 Aulasneo DEV. All rights reserved.",
|
|
1014
|
+
"document_title": "test test Certificate | Aulasneo DEV"
|
|
1015
|
+
},
|
|
1016
|
+
"custom_template": {
|
|
1017
|
+
"id": 1,
|
|
1018
|
+
"created": "2023-06-14 18:39:56.824500+00:00",
|
|
1019
|
+
"modified": "2023-06-14 18:46:46.156615+00:00",
|
|
1020
|
+
"name": "cert_template",
|
|
1021
|
+
"description": "Test template",
|
|
1022
|
+
"template": "<html><body>${accomplishment_banner_congrats}</body></html>"
|
|
1023
|
+
},
|
|
1024
|
+
"event_metadata": {
|
|
1025
|
+
"event_type": "CertificateRenderStarted",
|
|
1026
|
+
"time": "2023-06-14 18:05:54.815086"
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
The webhook processor can return a JSON with two objects: ``data`` and ``exception``.
|
|
1031
|
+
|
|
1032
|
+
**Example Response Payload:**
|
|
1033
|
+
|
|
1034
|
+
.. code-block:: json
|
|
1035
|
+
|
|
1036
|
+
{
|
|
1037
|
+
"data": {
|
|
1038
|
+
"context": {
|
|
1039
|
+
"additional_variable": "test test",
|
|
1040
|
+
"accomplishment_copy_name": "Name"
|
|
1041
|
+
},
|
|
1042
|
+
"custom_template": {
|
|
1043
|
+
"template": "<html><body>${additional_variable}</body></html>"
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
All data keys are optional, including those inside each nested object.
|
|
1049
|
+
|
|
1050
|
+
If you override any of the template fields, the change will apply only to this certificate rendering
|
|
1051
|
+
and will not modify the existing template.
|
|
1052
|
+
|
|
1053
|
+
**Exceptions:**
|
|
1054
|
+
|
|
1055
|
+
.. code-block:: json
|
|
1056
|
+
|
|
1057
|
+
{
|
|
1058
|
+
"exceptions": {
|
|
1059
|
+
"RedirectToPage": {
|
|
1060
|
+
"redirect_to": "<URL to redirect>"
|
|
1061
|
+
},
|
|
1062
|
+
"RenderCustomResponse": {
|
|
1063
|
+
"content": "<html content>",
|
|
1064
|
+
"content_type": "text/html; charset=utf-8",
|
|
1065
|
+
"status": 200,
|
|
1066
|
+
"reason": "OK",
|
|
1067
|
+
"charset": "utf-8",
|
|
1068
|
+
"headers": {
|
|
1069
|
+
"X-Custom-Header": "value"
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
"RenderAlternativeInvalidCertificate": {
|
|
1073
|
+
"template_name": "<template name or leave empty to render the standard invalid certificate>"
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
.. note::
|
|
1079
|
+
|
|
1080
|
+
Changes in the grade values do not affect the certificate or modify the user's grade.
|
|
1081
|
+
To update the certificate template, it must already exist, be active,
|
|
1082
|
+
and be associated with the course and organization.
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
def run_filter(self, context, custom_template): # pylint: disable=arguments-differ
|
|
1086
|
+
"""
|
|
1087
|
+
Execute a filter with the signature specified.
|
|
1088
|
+
|
|
1089
|
+
:param context: Context dictionary for the certificate template.
|
|
1090
|
+
:type context: dict
|
|
1091
|
+
|
|
1092
|
+
:param custom_template: edxapp object representing a custom web certificate template.
|
|
1093
|
+
:type custom_template: CertificateTemplate
|
|
1094
|
+
"""
|
|
1095
|
+
event = "CertificateRenderStarted"
|
|
1096
|
+
|
|
1097
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1098
|
+
|
|
1099
|
+
if webfilters:
|
|
1100
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1101
|
+
|
|
1102
|
+
user = get_user_model().objects.get(id=context.get('accomplishment_user_id'))
|
|
1103
|
+
course_key = CourseKey.from_string(context.get('course_id'))
|
|
1104
|
+
|
|
1105
|
+
data = {
|
|
1106
|
+
"context": context,
|
|
1107
|
+
"custom_template": custom_template,
|
|
1108
|
+
"completion_summary": get_course_blocks_completion_summary(course_key, user)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1112
|
+
data=data,
|
|
1113
|
+
exception=CertificateRenderStarted.RedirectToPage)
|
|
1114
|
+
|
|
1115
|
+
update_object(custom_template, content.get('custom_template'))
|
|
1116
|
+
|
|
1117
|
+
if 'context' in content:
|
|
1118
|
+
context.update(content.get('context', {}))
|
|
1119
|
+
|
|
1120
|
+
_check_for_exception(exceptions, CertificateRenderStarted.RedirectToPage)
|
|
1121
|
+
_check_for_exception(exceptions, CertificateRenderStarted.RenderAlternativeInvalidCertificate)
|
|
1122
|
+
_check_for_exception(exceptions, CertificateRenderStarted.RenderCustomResponse)
|
|
1123
|
+
|
|
1124
|
+
return {
|
|
1125
|
+
"context": context,
|
|
1126
|
+
"custom_template": custom_template,
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return {}
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class CohortChangeRequestedWebFilter(PipelineStep):
|
|
1133
|
+
"""
|
|
1134
|
+
Process CohortChangeRequested filter.
|
|
1135
|
+
|
|
1136
|
+
This filter is triggered when a user is about to be moved to another cohort.
|
|
1137
|
+
|
|
1138
|
+
It will POST a JSON payload to the webhook URL containing the cohort and user information.
|
|
1139
|
+
|
|
1140
|
+
**Example Request Payload:**
|
|
1141
|
+
|
|
1142
|
+
.. code-block:: json
|
|
1143
|
+
|
|
1144
|
+
{
|
|
1145
|
+
"current_membership": {
|
|
1146
|
+
"id": 1,
|
|
1147
|
+
"course_user_group_id": 2,
|
|
1148
|
+
"user_id": 4,
|
|
1149
|
+
"course_id": "course-v1:edX+DemoX+Demo_Course"
|
|
1150
|
+
},
|
|
1151
|
+
"target_cohort": {
|
|
1152
|
+
"id": 1,
|
|
1153
|
+
"name": "Cohort test",
|
|
1154
|
+
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
|
1155
|
+
"group_type": "cohort"
|
|
1156
|
+
},
|
|
1157
|
+
"user": {
|
|
1158
|
+
"id": 4,
|
|
1159
|
+
"last_login": "2023-06-21 16:43:46.264292+00:00",
|
|
1160
|
+
"is_superuser": true,
|
|
1161
|
+
"username": "andres",
|
|
1162
|
+
"first_name": "",
|
|
1163
|
+
"last_name": "",
|
|
1164
|
+
"email": "andres@aulasneo.com",
|
|
1165
|
+
"is_staff": true,
|
|
1166
|
+
"is_active": true,
|
|
1167
|
+
"date_joined": "2023-01-26 16:22:57.939766+00:00"
|
|
1168
|
+
},
|
|
1169
|
+
"user_profile": {
|
|
1170
|
+
"id": 2,
|
|
1171
|
+
"user_id": 4,
|
|
1172
|
+
"name": "John Doe",
|
|
1173
|
+
"meta": "",
|
|
1174
|
+
"courseware": "course.xml",
|
|
1175
|
+
"language": "",
|
|
1176
|
+
"location": "",
|
|
1177
|
+
"year_of_birth": null,
|
|
1178
|
+
"gender": null,
|
|
1179
|
+
"level_of_education": null,
|
|
1180
|
+
"mailing_address": null,
|
|
1181
|
+
"city": null,
|
|
1182
|
+
"country": null,
|
|
1183
|
+
"state": null,
|
|
1184
|
+
"goals": null,
|
|
1185
|
+
"bio": null,
|
|
1186
|
+
"profile_image_uploaded_at": null,
|
|
1187
|
+
"phone_number": null
|
|
1188
|
+
},
|
|
1189
|
+
"course_key": "course-v1:edX+DemoX+Demo_Course",
|
|
1190
|
+
"event_metadata": {
|
|
1191
|
+
"event_type": "CohortChangeRequested",
|
|
1192
|
+
"time": "2023-06-30 17:52:03.671230"
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
The webhook processor can return a JSON response with two top-level objects: ``data`` and ``exceptions``.
|
|
1197
|
+
|
|
1198
|
+
**Example Response Payload:**
|
|
1199
|
+
|
|
1200
|
+
.. code-block:: json
|
|
1201
|
+
|
|
1202
|
+
{
|
|
1203
|
+
"data": {
|
|
1204
|
+
"example values"
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
All ``data`` keys are optional, including any nested fields.
|
|
1209
|
+
|
|
1210
|
+
**Exceptions:**
|
|
1211
|
+
|
|
1212
|
+
.. code-block:: json
|
|
1213
|
+
|
|
1214
|
+
{
|
|
1215
|
+
"exceptions": {
|
|
1216
|
+
"PreventCohortChange": "<message>"
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
"""
|
|
1220
|
+
|
|
1221
|
+
def run_filter(self, current_membership, target_cohort): # pylint: disable=arguments-differ
|
|
1222
|
+
"""
|
|
1223
|
+
Execute a filter with the signature specified.
|
|
1224
|
+
|
|
1225
|
+
:param current_membership: edxapp object representing the user's current cohort membership.
|
|
1226
|
+
:type current_membership: CohortMembership
|
|
1227
|
+
|
|
1228
|
+
:param target_cohort: edxapp object representing the new cohort the user will be assigned to.
|
|
1229
|
+
:type target_cohort: CourseUserGroup
|
|
1230
|
+
"""
|
|
1231
|
+
event = "CohortChangeRequested"
|
|
1232
|
+
|
|
1233
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1234
|
+
|
|
1235
|
+
if webfilters:
|
|
1236
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1237
|
+
|
|
1238
|
+
user = get_user_model().objects.get(id=current_membership.user_id)
|
|
1239
|
+
user_profile = user.profile
|
|
1240
|
+
course_key = current_membership.course_id
|
|
1241
|
+
|
|
1242
|
+
data = {
|
|
1243
|
+
"current_membership": current_membership,
|
|
1244
|
+
"target_cohort": target_cohort,
|
|
1245
|
+
"user": user,
|
|
1246
|
+
"user_profile": user_profile,
|
|
1247
|
+
"course_key": course_key
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1251
|
+
data=data,
|
|
1252
|
+
exception=CohortChangeRequested.PreventCohortChange)
|
|
1253
|
+
|
|
1254
|
+
_check_for_exception(exceptions, CohortChangeRequested.PreventCohortChange)
|
|
1255
|
+
update_object(current_membership, content.get('current_membership'))
|
|
1256
|
+
update_object(target_cohort, content.get('target_cohort'))
|
|
1257
|
+
|
|
1258
|
+
return {
|
|
1259
|
+
"current_membership": current_membership,
|
|
1260
|
+
"target_cohort": target_cohort,
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
return {}
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
class CohortAssignmentRequestedWebFilter(PipelineStep):
|
|
1267
|
+
"""
|
|
1268
|
+
Process CohortAssignmentRequested filter.
|
|
1269
|
+
|
|
1270
|
+
This filter is triggered when a user is about to be assigned to a cohort.
|
|
1271
|
+
|
|
1272
|
+
It will POST a JSON payload to the webhook URL containing the cohort object.
|
|
1273
|
+
|
|
1274
|
+
**Example Request Payload:**
|
|
1275
|
+
|
|
1276
|
+
.. code-block:: json
|
|
1277
|
+
|
|
1278
|
+
{
|
|
1279
|
+
"target_cohort": {
|
|
1280
|
+
"id": 1,
|
|
1281
|
+
"name": "Cohort test",
|
|
1282
|
+
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
|
1283
|
+
"group_type": "cohort"
|
|
1284
|
+
},
|
|
1285
|
+
"user": {
|
|
1286
|
+
"id": 4,
|
|
1287
|
+
"last_login": "2023-06-21 16:43:46.264292+00:00",
|
|
1288
|
+
"is_superuser": true,
|
|
1289
|
+
"username": "andres",
|
|
1290
|
+
"first_name": "",
|
|
1291
|
+
"last_name": "",
|
|
1292
|
+
"email": "andres@aulasneo.com",
|
|
1293
|
+
"is_staff": true,
|
|
1294
|
+
"is_active": true,
|
|
1295
|
+
"date_joined": "2023-01-26 16:22:57.939766+00:00"
|
|
1296
|
+
},
|
|
1297
|
+
"user_profile": {
|
|
1298
|
+
"id": 2,
|
|
1299
|
+
"user_id": 4,
|
|
1300
|
+
"name": "John Doe",
|
|
1301
|
+
"meta": "",
|
|
1302
|
+
"courseware": "course.xml",
|
|
1303
|
+
"language": "",
|
|
1304
|
+
"location": "",
|
|
1305
|
+
"year_of_birth": null,
|
|
1306
|
+
"gender": null,
|
|
1307
|
+
"level_of_education": null,
|
|
1308
|
+
"mailing_address": null,
|
|
1309
|
+
"city": null,
|
|
1310
|
+
"country": null,
|
|
1311
|
+
"state": null,
|
|
1312
|
+
"goals": null,
|
|
1313
|
+
"bio": null,
|
|
1314
|
+
"profile_image_uploaded_at": null,
|
|
1315
|
+
"phone_number": null
|
|
1316
|
+
},
|
|
1317
|
+
"course_key": "course-v1:edX+DemoX+Demo_Course",
|
|
1318
|
+
"event_metadata": {
|
|
1319
|
+
"event_type": "CohortChangeRequested",
|
|
1320
|
+
"time": "2023-06-30 17:52:03.671230"
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
The webhook processor can return a JSON response with two objects: ``data`` and ``exception``.
|
|
1325
|
+
|
|
1326
|
+
**Example Response Payload:**
|
|
1327
|
+
|
|
1328
|
+
.. code-block:: json
|
|
1329
|
+
|
|
1330
|
+
{
|
|
1331
|
+
"data": {
|
|
1332
|
+
"example values"
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
All ``data`` keys are optional, including nested keys within objects.
|
|
1337
|
+
|
|
1338
|
+
**Exceptions:**
|
|
1339
|
+
|
|
1340
|
+
.. code-block:: json
|
|
1341
|
+
|
|
1342
|
+
{
|
|
1343
|
+
"exceptions": {
|
|
1344
|
+
"PreventCohortAssignment": "<message>"
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
.. note::
|
|
1349
|
+
|
|
1350
|
+
Currently, the exception message is logged in the console but is not shown to the user.
|
|
1351
|
+
"""
|
|
1352
|
+
|
|
1353
|
+
def run_filter(self, user, target_cohort): # pylint: disable=arguments-differ
|
|
1354
|
+
"""
|
|
1355
|
+
Execute a filter with the signature specified.
|
|
1356
|
+
|
|
1357
|
+
Arguments:
|
|
1358
|
+
user (User): is a Django User object to be added in the cohort.
|
|
1359
|
+
target_cohort (CourseUserGroup): edxapp object representing the new user's cohort.
|
|
1360
|
+
"""
|
|
1361
|
+
event = "CohortAssignmentRequested"
|
|
1362
|
+
|
|
1363
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1364
|
+
|
|
1365
|
+
if webfilters:
|
|
1366
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1367
|
+
|
|
1368
|
+
user_profile = user.profile
|
|
1369
|
+
course_key = target_cohort.course_id
|
|
1370
|
+
|
|
1371
|
+
data = {
|
|
1372
|
+
"target_cohort": target_cohort,
|
|
1373
|
+
"user": user,
|
|
1374
|
+
"user_profile": user_profile,
|
|
1375
|
+
"course_key": course_key
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1379
|
+
data=data,
|
|
1380
|
+
exception=CohortAssignmentRequested.PreventCohortAssignment)
|
|
1381
|
+
|
|
1382
|
+
_check_for_exception(exceptions, CohortAssignmentRequested.PreventCohortAssignment)
|
|
1383
|
+
update_object(user, content.get('user'))
|
|
1384
|
+
update_object(user.profile, content.get('user_profile'))
|
|
1385
|
+
update_object(target_cohort, content.get('target_cohort'))
|
|
1386
|
+
|
|
1387
|
+
return {
|
|
1388
|
+
"user": user,
|
|
1389
|
+
"target_cohort": target_cohort,
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
return {}
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
class CourseAboutRenderStartedWebFilter(PipelineStep):
|
|
1396
|
+
"""
|
|
1397
|
+
Process CourseAboutRenderStarted filter.
|
|
1398
|
+
|
|
1399
|
+
This filter is triggered when the course "About" page is about to be rendered.
|
|
1400
|
+
|
|
1401
|
+
It will POST a JSON payload to the webhook URL with the course context object.
|
|
1402
|
+
|
|
1403
|
+
**Example Request Payload:**
|
|
1404
|
+
|
|
1405
|
+
.. code-block:: json
|
|
1406
|
+
|
|
1407
|
+
{
|
|
1408
|
+
"context": {
|
|
1409
|
+
"course": {
|
|
1410
|
+
"some values"
|
|
1411
|
+
},
|
|
1412
|
+
"course_details": {
|
|
1413
|
+
"some values"
|
|
1414
|
+
},
|
|
1415
|
+
"staff_access": true,
|
|
1416
|
+
"studio_url": "//studio.local.openedx.io/settings/details/course-v1:edX+DemoX+Demo_Course",
|
|
1417
|
+
"registered": true,
|
|
1418
|
+
"course_target": "http://apps.local.openedx.io/learning/course/course-v1:edX+DemoX+Demo_Course/home",
|
|
1419
|
+
"some values"
|
|
1420
|
+
},
|
|
1421
|
+
"template_name": "courseware/course_about.html",
|
|
1422
|
+
"event_metadata": {
|
|
1423
|
+
"event_type": "CourseAboutRenderStarted",
|
|
1424
|
+
"time": "2023-08-09 20:53:15.420093"
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
**Example Response Payload:**
|
|
1429
|
+
|
|
1430
|
+
.. code-block:: json
|
|
1431
|
+
|
|
1432
|
+
{
|
|
1433
|
+
"data": {
|
|
1434
|
+
"context": {
|
|
1435
|
+
"can_enroll": false
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
All ``data`` keys are optional, including the fields inside each nested object.
|
|
1441
|
+
|
|
1442
|
+
.. note::
|
|
1443
|
+
|
|
1444
|
+
The ``course`` and ``course_details`` fields are provided for informational purposes only
|
|
1445
|
+
and **cannot** be modified by the webfilter.
|
|
1446
|
+
|
|
1447
|
+
**Exceptions:**
|
|
1448
|
+
|
|
1449
|
+
.. code-block:: json
|
|
1450
|
+
|
|
1451
|
+
{
|
|
1452
|
+
"exception": {
|
|
1453
|
+
"RedirectToPage": {
|
|
1454
|
+
"redirect_to": "<URL to redirect>"
|
|
1455
|
+
},
|
|
1456
|
+
"RenderCustomResponse": {
|
|
1457
|
+
"content": "<html content>",
|
|
1458
|
+
"content_type": "text/html; charset=utf-8",
|
|
1459
|
+
"status": 200,
|
|
1460
|
+
"reason": "OK",
|
|
1461
|
+
"charset": "utf-8",
|
|
1462
|
+
"headers": {
|
|
1463
|
+
"X-Custom-Header": "value"
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
"RenderInvalidCourseAbout": {
|
|
1467
|
+
"course_about_template": "<template to render the standard invalid course about page>",
|
|
1468
|
+
"template_context": {
|
|
1469
|
+
"<key>": "<value>"
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
.. note::
|
|
1476
|
+
|
|
1477
|
+
Currently, exception messages are logged to the console but are not shown to the user.
|
|
1478
|
+
"""
|
|
1479
|
+
|
|
1480
|
+
def run_filter(self, context, template_name): # pylint: disable=arguments-differ
|
|
1481
|
+
"""
|
|
1482
|
+
Execute a filter with the signature specified.
|
|
1483
|
+
|
|
1484
|
+
Arguments:
|
|
1485
|
+
context (dict): context dictionary for course about template.
|
|
1486
|
+
template_name (str): template name to be rendered by the course about.
|
|
1487
|
+
"""
|
|
1488
|
+
event = "CourseAboutRenderStarted"
|
|
1489
|
+
|
|
1490
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1491
|
+
|
|
1492
|
+
if webfilters:
|
|
1493
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1494
|
+
|
|
1495
|
+
course = context.get('course')
|
|
1496
|
+
course_details = context.get('course_details')
|
|
1497
|
+
|
|
1498
|
+
# Convert the course and course_details objects to dicts
|
|
1499
|
+
context_to_send = context.copy()
|
|
1500
|
+
context_to_send['course'] = object_serializer(course)
|
|
1501
|
+
context_to_send['course_details'] = object_serializer(course_details)
|
|
1502
|
+
|
|
1503
|
+
data = {
|
|
1504
|
+
"context": context_to_send,
|
|
1505
|
+
"template_name": template_name,
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1509
|
+
data=data,
|
|
1510
|
+
exception=CourseAboutRenderStarted.RedirectToPage)
|
|
1511
|
+
|
|
1512
|
+
# The response data will have a course and a course_details objects that are immutable
|
|
1513
|
+
updated_context = {**context, **content.get('context', {})}
|
|
1514
|
+
updated_context.update({'course': course, 'course_details': course_details})
|
|
1515
|
+
|
|
1516
|
+
_check_for_exception(exceptions, CourseAboutRenderStarted.RedirectToPage)
|
|
1517
|
+
_check_for_exception(exceptions, CourseAboutRenderStarted.RenderCustomResponse)
|
|
1518
|
+
_check_for_exception(exceptions, CourseAboutRenderStarted.RenderInvalidCourseAbout)
|
|
1519
|
+
|
|
1520
|
+
return {
|
|
1521
|
+
"context": updated_context,
|
|
1522
|
+
"template_name": content.get('template_name') or template_name,
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return {}
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
class DashboardRenderStartedWebFilter(PipelineStep):
|
|
1529
|
+
"""
|
|
1530
|
+
Process DashboardRenderStarted filter.
|
|
1531
|
+
|
|
1532
|
+
This filter is triggered when the user dashboard page is about to be rendered.
|
|
1533
|
+
|
|
1534
|
+
It will POST a JSON payload to the webhook URL with the dashboard context object.
|
|
1535
|
+
|
|
1536
|
+
**Example Request Payload:**
|
|
1537
|
+
|
|
1538
|
+
.. code-block:: json
|
|
1539
|
+
|
|
1540
|
+
{
|
|
1541
|
+
"context": {
|
|
1542
|
+
"urls": {},
|
|
1543
|
+
"programs_data": {},
|
|
1544
|
+
"enterprise_message": "",
|
|
1545
|
+
"consent_required_courses": "set()",
|
|
1546
|
+
"enrollment_message": null,
|
|
1547
|
+
"redirect_message": "",
|
|
1548
|
+
"account_activation_messages": [],
|
|
1549
|
+
"activate_account_message": "",
|
|
1550
|
+
"course_enrollments": [
|
|
1551
|
+
"[CourseEnrollment] andres: course-v1:test+test+test (2023-02-14 14:49:26); active: (True)"
|
|
1552
|
+
],
|
|
1553
|
+
"more values"
|
|
1554
|
+
},
|
|
1555
|
+
"template_name": "dashboard.html",
|
|
1556
|
+
"event_metadata": {
|
|
1557
|
+
"event_type": "DashboardRenderStarted",
|
|
1558
|
+
"time": "2023-08-14 22:30:32.013894"
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
**Example Response Payload:**
|
|
1563
|
+
|
|
1564
|
+
.. code-block:: json
|
|
1565
|
+
|
|
1566
|
+
{
|
|
1567
|
+
"data": {
|
|
1568
|
+
"context": {
|
|
1569
|
+
"can_enroll": false
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
All ``data`` keys are optional, including nested keys inside each object.
|
|
1575
|
+
|
|
1576
|
+
.. note::
|
|
1577
|
+
|
|
1578
|
+
Some context fields such as ``course`` and ``course_details`` (when present) are informational only
|
|
1579
|
+
and **cannot** be modified by the webfilter.
|
|
1580
|
+
|
|
1581
|
+
**Exceptions:**
|
|
1582
|
+
|
|
1583
|
+
.. code-block:: json
|
|
1584
|
+
|
|
1585
|
+
{
|
|
1586
|
+
"exception": {
|
|
1587
|
+
"RedirectToPage": {
|
|
1588
|
+
"redirect_to": "<URL to redirect>"
|
|
1589
|
+
},
|
|
1590
|
+
"RenderCustomResponse": {
|
|
1591
|
+
"content": "<html content>",
|
|
1592
|
+
"content_type": "text/html; charset=utf-8",
|
|
1593
|
+
"status": 200,
|
|
1594
|
+
"reason": "OK",
|
|
1595
|
+
"charset": "utf-8",
|
|
1596
|
+
"headers": {
|
|
1597
|
+
"X-Custom-Header": "value"
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
"RenderInvalidCourseAbout": {
|
|
1601
|
+
"course_about_template": "<template to render the standard invalid course about page>",
|
|
1602
|
+
"template_context": {
|
|
1603
|
+
"<key>": "<value>"
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
.. note::
|
|
1610
|
+
|
|
1611
|
+
Currently, exception messages are logged to the console but are not shown to the user.
|
|
1612
|
+
"""
|
|
1613
|
+
|
|
1614
|
+
def run_filter(self, context, template_name): # pylint: disable=arguments-differ
|
|
1615
|
+
"""
|
|
1616
|
+
Execute a filter with the signature specified.
|
|
1617
|
+
|
|
1618
|
+
Arguments:
|
|
1619
|
+
context (dict): context dictionary for course about template.
|
|
1620
|
+
template_name (str): template name to be rendered by the course about.
|
|
1621
|
+
"""
|
|
1622
|
+
event = "DashboardRenderStarted"
|
|
1623
|
+
|
|
1624
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1625
|
+
|
|
1626
|
+
if webfilters:
|
|
1627
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1628
|
+
|
|
1629
|
+
data = {
|
|
1630
|
+
"context": context,
|
|
1631
|
+
"template_name": template_name,
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1635
|
+
data=data,
|
|
1636
|
+
exception=DashboardRenderStarted.RedirectToPage)
|
|
1637
|
+
|
|
1638
|
+
context.update(content.get('context', {}))
|
|
1639
|
+
|
|
1640
|
+
_check_for_exception(exceptions, DashboardRenderStarted.RedirectToPage)
|
|
1641
|
+
_check_for_exception(exceptions, DashboardRenderStarted.RenderCustomResponse)
|
|
1642
|
+
_check_for_exception(exceptions, DashboardRenderStarted.RenderInvalidDashboard)
|
|
1643
|
+
|
|
1644
|
+
return {
|
|
1645
|
+
"context": context,
|
|
1646
|
+
"template_name": content.get('template_name') or template_name,
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
return {}
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
# New in Redwood
|
|
1653
|
+
class VerticalBlockChildRenderStartedWebFilter(PipelineStep):
|
|
1654
|
+
"""
|
|
1655
|
+
Filter used to modify the rendering of a child block within a vertical block.
|
|
1656
|
+
|
|
1657
|
+
Purpose:
|
|
1658
|
+
This filter is triggered when a child block is about to be rendered within a vertical block, allowing the filter
|
|
1659
|
+
to act on the block and the context used to render the child block.
|
|
1660
|
+
|
|
1661
|
+
Filter Type:
|
|
1662
|
+
org.openedx.learning.vertical_block_child.render.started.v1
|
|
1663
|
+
|
|
1664
|
+
Trigger:
|
|
1665
|
+
- Repository: openedx/edx-platform
|
|
1666
|
+
- Path: xmodule/vertical_block.py
|
|
1667
|
+
- Function or Method: VerticalBlock._student_or_public_view
|
|
1668
|
+
"""
|
|
1669
|
+
|
|
1670
|
+
def run_filter(self, **data):
|
|
1671
|
+
"""
|
|
1672
|
+
:param data = block: Any, context: dict[str, Any].
|
|
1673
|
+
"""
|
|
1674
|
+
# The event is the class name, except the last "WebFilter"
|
|
1675
|
+
event = type(self).__name__[:-9]
|
|
1676
|
+
|
|
1677
|
+
return_data = data.copy()
|
|
1678
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1679
|
+
|
|
1680
|
+
if webfilters:
|
|
1681
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1682
|
+
|
|
1683
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1684
|
+
data=object_serializer(data),
|
|
1685
|
+
exception=VerticalBlockChildRenderStarted.PreventChildBlockRender)
|
|
1686
|
+
|
|
1687
|
+
return_data['context'].update(content.get('context', {}))
|
|
1688
|
+
|
|
1689
|
+
_check_for_exception(exceptions, VerticalBlockChildRenderStarted.PreventChildBlockRender)
|
|
1690
|
+
|
|
1691
|
+
return return_data
|
|
1692
|
+
|
|
1693
|
+
return {}
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
class CourseEnrollmentQuerysetRequestedWebFilter(PipelineStep):
|
|
1697
|
+
"""
|
|
1698
|
+
Filter used to modify the QuerySet of course enrollments.
|
|
1699
|
+
|
|
1700
|
+
Purpose:
|
|
1701
|
+
This filter is triggered when a QuerySet of course enrollments is requested, allowing the filter to act on the
|
|
1702
|
+
enrollments data.
|
|
1703
|
+
|
|
1704
|
+
Filter Type:
|
|
1705
|
+
org.openedx.learning.course_enrollment_queryset.requested.v1
|
|
1706
|
+
|
|
1707
|
+
Trigger: NA
|
|
1708
|
+
|
|
1709
|
+
Additional Information:
|
|
1710
|
+
This filter is not currently triggered by any specific function or method in any codebase. It should be
|
|
1711
|
+
marked to be removed if it's not used. See openedx-filters#245 for more information.
|
|
1712
|
+
"""
|
|
1713
|
+
|
|
1714
|
+
def run_filter(self, **data):
|
|
1715
|
+
"""
|
|
1716
|
+
:param data = enrollments: QuerySet.
|
|
1717
|
+
"""
|
|
1718
|
+
# The event is the class name, except the last "WebFilter"
|
|
1719
|
+
event = type(self).__name__[:-9]
|
|
1720
|
+
|
|
1721
|
+
return_data = data.copy()
|
|
1722
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1723
|
+
|
|
1724
|
+
if webfilters:
|
|
1725
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1726
|
+
|
|
1727
|
+
return_data['enrollments'] = list(data['enrollments'].values())
|
|
1728
|
+
|
|
1729
|
+
content, exceptions = _process_filter(
|
|
1730
|
+
webfilters=webfilters,
|
|
1731
|
+
data=return_data,
|
|
1732
|
+
exception=CourseEnrollmentQuerysetRequested.PreventEnrollmentQuerysetRequest)
|
|
1733
|
+
|
|
1734
|
+
return_data['enrollments'] = data['enrollments'].filter(**content.get('filter', {}))
|
|
1735
|
+
|
|
1736
|
+
_check_for_exception(exceptions, CourseEnrollmentQuerysetRequested.PreventEnrollmentQuerysetRequest)
|
|
1737
|
+
|
|
1738
|
+
return return_data
|
|
1739
|
+
|
|
1740
|
+
return {}
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
class RenderXBlockStartedWebFilter(PipelineStep):
|
|
1744
|
+
"""
|
|
1745
|
+
Filter in between context generation and rendering of XBlock scope.
|
|
1746
|
+
|
|
1747
|
+
Purpose:
|
|
1748
|
+
This filter is triggered when an XBlock is about to be rendered,
|
|
1749
|
+
just before the rendering process is completed
|
|
1750
|
+
allowing the filter to act on the context and student_view_context used to render the XBlock.
|
|
1751
|
+
|
|
1752
|
+
Filter Type:
|
|
1753
|
+
org.openedx.learning.xblock.render.started.v1
|
|
1754
|
+
|
|
1755
|
+
Trigger:
|
|
1756
|
+
- Repository: openedx/edx-platform
|
|
1757
|
+
- Path: lms/djangoapps/courseware/views/views.py
|
|
1758
|
+
- Function or Method: render_xblock
|
|
1759
|
+
"""
|
|
1760
|
+
|
|
1761
|
+
def run_filter(self, **data):
|
|
1762
|
+
"""
|
|
1763
|
+
:param data = context: dict[str, Any], student_view_context: dict.
|
|
1764
|
+
"""
|
|
1765
|
+
# The event is the class name, except the last "WebFilter"
|
|
1766
|
+
event = type(self).__name__[:-9]
|
|
1767
|
+
|
|
1768
|
+
return_data = data.copy()
|
|
1769
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1770
|
+
|
|
1771
|
+
if webfilters:
|
|
1772
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1773
|
+
|
|
1774
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1775
|
+
data=data,
|
|
1776
|
+
exception=RenderXBlockStarted.PreventXBlockBlockRender)
|
|
1777
|
+
|
|
1778
|
+
return_data.update(content)
|
|
1779
|
+
|
|
1780
|
+
_check_for_exception(exceptions, RenderXBlockStarted.PreventXBlockBlockRender)
|
|
1781
|
+
_check_for_exception(exceptions, RenderXBlockStarted.RenderCustomResponse)
|
|
1782
|
+
|
|
1783
|
+
return return_data
|
|
1784
|
+
|
|
1785
|
+
return {}
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
class VerticalBlockRenderCompletedWebFilter(PipelineStep):
|
|
1789
|
+
"""
|
|
1790
|
+
Filter used to act on vertical block rendering completed.
|
|
1791
|
+
|
|
1792
|
+
Purpose:
|
|
1793
|
+
This filter is triggered when a vertical block is rendered, just after the rendering process is completed
|
|
1794
|
+
allowing the filter to act on the block, fragment, context, and view used to render the vertical block.
|
|
1795
|
+
|
|
1796
|
+
Filter Type:
|
|
1797
|
+
org.openedx.learning.vertical_block.render.completed.v1
|
|
1798
|
+
|
|
1799
|
+
Trigger:
|
|
1800
|
+
- Repository: openedx/edx-platform
|
|
1801
|
+
- Path: xmodule/vertical_block.py
|
|
1802
|
+
- Function or Method: VerticalBlock._student_or_public_view
|
|
1803
|
+
"""
|
|
1804
|
+
|
|
1805
|
+
def run_filter(self, **data):
|
|
1806
|
+
"""
|
|
1807
|
+
:param data = block: Any, fragment: Any, context: dict[str, Any], view: str.
|
|
1808
|
+
|
|
1809
|
+
Process the inputs using the configured pipeline steps to modify the rendering of a vertical block.
|
|
1810
|
+
|
|
1811
|
+
Arguments:
|
|
1812
|
+
block (VerticalBlock): The VeriticalBlock instance which is being rendered.
|
|
1813
|
+
fragment (web_fragments.Fragment): The web-fragment containing the rendered content of VerticalBlock.
|
|
1814
|
+
context (dict): rendering context values like is_mobile_app, show_title..etc.
|
|
1815
|
+
view (str): the rendering view. Can be either 'student_view', or 'public_view'.
|
|
1816
|
+
|
|
1817
|
+
Returns:
|
|
1818
|
+
tuple[VeticalBlock, web_fragments.Fragment, dict, str]:
|
|
1819
|
+
- VerticalBlock: The VeriticalBlock instance which is being rendered.
|
|
1820
|
+
- web_fragments.Fragment: The web-fragment containing the rendered content of VerticalBlock.
|
|
1821
|
+
- dict: rendering context values like is_mobile_app, show_title..etc.
|
|
1822
|
+
- str: the rendering view. Can be either 'student_view', or 'public_view'.
|
|
1823
|
+
"""
|
|
1824
|
+
# The event is the class name, except the last "WebFilter"
|
|
1825
|
+
event = type(self).__name__[:-9]
|
|
1826
|
+
|
|
1827
|
+
return_data = data.copy()
|
|
1828
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1829
|
+
|
|
1830
|
+
if webfilters:
|
|
1831
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1832
|
+
|
|
1833
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
1834
|
+
data=data,
|
|
1835
|
+
exception=VerticalBlockRenderCompleted.PreventVerticalBlockRender)
|
|
1836
|
+
|
|
1837
|
+
return_data['context'].update(content.get('context', {}))
|
|
1838
|
+
return_data['view'] = content.get('view', data.get('view'))
|
|
1839
|
+
|
|
1840
|
+
_check_for_exception(exceptions, VerticalBlockRenderCompleted.PreventVerticalBlockRender)
|
|
1841
|
+
|
|
1842
|
+
return return_data
|
|
1843
|
+
|
|
1844
|
+
return {}
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
class CourseHomeUrlCreationStartedWebFilter(PipelineStep):
|
|
1848
|
+
"""
|
|
1849
|
+
Filter used to modify the course home url creation process.
|
|
1850
|
+
|
|
1851
|
+
Purpose:
|
|
1852
|
+
This filter is triggered when a course home url is being generated, just before the generation process is
|
|
1853
|
+
completed allowing the filter to act on the course key and course home url.
|
|
1854
|
+
|
|
1855
|
+
Filter Type:
|
|
1856
|
+
org.openedx.learning.course.homepage.url.creation.started.v1
|
|
1857
|
+
|
|
1858
|
+
Trigger:
|
|
1859
|
+
- Repository: openedx/edx-platform
|
|
1860
|
+
- Path: openedx/features/course_experience/__init__.py
|
|
1861
|
+
- Function or Method: course_home_url
|
|
1862
|
+
"""
|
|
1863
|
+
|
|
1864
|
+
def run_filter(self, **data):
|
|
1865
|
+
"""
|
|
1866
|
+
:param data = course_key: CourseKey, course_home_url: str.
|
|
1867
|
+
"""
|
|
1868
|
+
# The event is the class name, except the last "WebFilter"
|
|
1869
|
+
event = type(self).__name__[:-9]
|
|
1870
|
+
|
|
1871
|
+
return_data = data.copy()
|
|
1872
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1873
|
+
|
|
1874
|
+
if webfilters:
|
|
1875
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1876
|
+
|
|
1877
|
+
course_key = data.get('course_key')
|
|
1878
|
+
course_id = str(course_key)
|
|
1879
|
+
|
|
1880
|
+
content, _ = _process_filter(
|
|
1881
|
+
webfilters=webfilters,
|
|
1882
|
+
data={"course_id": course_id, "course_home_url": data.get('course_home_url')},
|
|
1883
|
+
exception=None)
|
|
1884
|
+
|
|
1885
|
+
return_data['course_home_url'] = content.get('course_home_url', data.get('course_home_url'))
|
|
1886
|
+
|
|
1887
|
+
return return_data
|
|
1888
|
+
|
|
1889
|
+
return {}
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
class CourseEnrollmentAPIRenderStartedWebFilter(PipelineStep):
|
|
1893
|
+
"""
|
|
1894
|
+
Filter used to modify the course enrollment API rendering process.
|
|
1895
|
+
|
|
1896
|
+
Purpose:
|
|
1897
|
+
This filter is triggered when a user requests to view the course enrollment API, just before the API is rendered
|
|
1898
|
+
allowing the filter to act on the course key and serialized enrollment data.
|
|
1899
|
+
|
|
1900
|
+
Filter Type:
|
|
1901
|
+
org.openedx.learning.home.enrollment.api.rendered.v1
|
|
1902
|
+
|
|
1903
|
+
Trigger:
|
|
1904
|
+
- Repository: openedx/edx-platform
|
|
1905
|
+
- Path: lms/djangoapps/learner_home/serializers.py
|
|
1906
|
+
- Function or Method: EnrollmentSerializer.to_representation
|
|
1907
|
+
"""
|
|
1908
|
+
|
|
1909
|
+
def run_filter(self, **data):
|
|
1910
|
+
"""
|
|
1911
|
+
:param data = course_key: CourseKey, serialized_enrollment: dict[str, Any].
|
|
1912
|
+
"""
|
|
1913
|
+
# The event is the class name, except the last "WebFilter"
|
|
1914
|
+
event = type(self).__name__[:-9]
|
|
1915
|
+
|
|
1916
|
+
return_data = data.copy()
|
|
1917
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1918
|
+
|
|
1919
|
+
if webfilters:
|
|
1920
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1921
|
+
|
|
1922
|
+
course_key = data.get('course_key')
|
|
1923
|
+
course_id = str(course_key)
|
|
1924
|
+
|
|
1925
|
+
content, _ = _process_filter(
|
|
1926
|
+
webfilters=webfilters,
|
|
1927
|
+
data={"course_id": course_id, "serialized_enrollment": data.get('serialized_enrollment')},
|
|
1928
|
+
exception=None)
|
|
1929
|
+
|
|
1930
|
+
return_data['serialized_enrollment'] = content.get('serialized_enrollment',
|
|
1931
|
+
data.get('serialized_enrollment'))
|
|
1932
|
+
|
|
1933
|
+
return return_data
|
|
1934
|
+
|
|
1935
|
+
return {}
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
class CourseRunAPIRenderStartedWebFilter(PipelineStep):
|
|
1939
|
+
"""
|
|
1940
|
+
Filter used to modify the course run API rendering process.
|
|
1941
|
+
|
|
1942
|
+
Purpose:
|
|
1943
|
+
This filter is triggered when a user requests to view the course run API, just before the API is rendered
|
|
1944
|
+
allowing the filter to act on the serialized course run data.
|
|
1945
|
+
|
|
1946
|
+
Filter Type:
|
|
1947
|
+
org.openedx.learning.home.courserun.api.rendered.started.v1
|
|
1948
|
+
|
|
1949
|
+
Trigger:
|
|
1950
|
+
- Repository: openedx/edx-platform
|
|
1951
|
+
- Path: lms/djangoapps/learner_home/serializers.py
|
|
1952
|
+
- Function or Method: CourseRunSerializer.to_representation
|
|
1953
|
+
"""
|
|
1954
|
+
|
|
1955
|
+
def run_filter(self, **data):
|
|
1956
|
+
"""
|
|
1957
|
+
:param data = serialized_courserun: dict[str, Any].
|
|
1958
|
+
"""
|
|
1959
|
+
# The event is the class name, except the last "WebFilter"
|
|
1960
|
+
event = type(self).__name__[:-9]
|
|
1961
|
+
|
|
1962
|
+
return_data = data.copy()
|
|
1963
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
1964
|
+
|
|
1965
|
+
if webfilters:
|
|
1966
|
+
logger.info(f"Webfilter for {event} event.")
|
|
1967
|
+
|
|
1968
|
+
content, _ = _process_filter(webfilters=webfilters, data=data, exception=None)
|
|
1969
|
+
|
|
1970
|
+
return_data.update(content)
|
|
1971
|
+
|
|
1972
|
+
return return_data
|
|
1973
|
+
|
|
1974
|
+
return {}
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
class InstructorDashboardRenderStartedWebFilter(PipelineStep):
|
|
1978
|
+
"""
|
|
1979
|
+
Filter used to modify the instructor dashboard rendering process.
|
|
1980
|
+
|
|
1981
|
+
Purpose:
|
|
1982
|
+
This filter is triggered when an instructor requests to view the dashboard, just before the page is rendered
|
|
1983
|
+
allowing the filter to act on the context and the template used to render the page.
|
|
1984
|
+
|
|
1985
|
+
Filter Type:
|
|
1986
|
+
org.openedx.learning.instructor.dashboard.render.started.v1
|
|
1987
|
+
|
|
1988
|
+
Trigger:
|
|
1989
|
+
- Repository: openedx/edx-platform
|
|
1990
|
+
- Path: lms/djangoapps/instructor/views/instructor_dashboard.py
|
|
1991
|
+
- Function or Method: instructor_dashboard_2
|
|
1992
|
+
"""
|
|
1993
|
+
|
|
1994
|
+
def run_filter(self, **data):
|
|
1995
|
+
"""
|
|
1996
|
+
:param data = context: dict[str, Any], template_name: str.
|
|
1997
|
+
"""
|
|
1998
|
+
# The event is the class name, except the last "WebFilter"
|
|
1999
|
+
event = type(self).__name__[:-9]
|
|
2000
|
+
|
|
2001
|
+
return_data = data.copy()
|
|
2002
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
2003
|
+
|
|
2004
|
+
if webfilters:
|
|
2005
|
+
logger.info(f"Webfilter for {event} event.")
|
|
2006
|
+
|
|
2007
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
2008
|
+
data=data,
|
|
2009
|
+
exception=InstructorDashboardRenderStarted.RenderInvalidDashboard)
|
|
2010
|
+
|
|
2011
|
+
return_data.update(content)
|
|
2012
|
+
|
|
2013
|
+
_check_for_exception(exceptions, InstructorDashboardRenderStarted.RedirectToPage)
|
|
2014
|
+
_check_for_exception(exceptions, InstructorDashboardRenderStarted.RenderInvalidDashboard)
|
|
2015
|
+
_check_for_exception(exceptions, InstructorDashboardRenderStarted.RenderCustomResponse)
|
|
2016
|
+
|
|
2017
|
+
return return_data
|
|
2018
|
+
|
|
2019
|
+
return {}
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
class ORASubmissionViewRenderStartedWebFilter(PipelineStep):
|
|
2023
|
+
"""
|
|
2024
|
+
Filter used to modify the submission view rendering process.
|
|
2025
|
+
|
|
2026
|
+
Purpose:
|
|
2027
|
+
This filter is triggered when a user requests to view the submission,
|
|
2028
|
+
just before the page is rendered allowing
|
|
2029
|
+
the filter to act on the context and the template used to render the page.
|
|
2030
|
+
|
|
2031
|
+
Filter Type:
|
|
2032
|
+
org.openedx.learning.ora.submission_view.render.started.v1
|
|
2033
|
+
|
|
2034
|
+
Trigger:
|
|
2035
|
+
- Repository: openedx/edx-ora2
|
|
2036
|
+
- Path: openassessment/xblock/ui_mixins/legacy/views/submission.py
|
|
2037
|
+
- Function or Method: render_submission
|
|
2038
|
+
"""
|
|
2039
|
+
|
|
2040
|
+
def run_filter(self, **data):
|
|
2041
|
+
"""
|
|
2042
|
+
:param data = context: dict[str, Any], template_name: str.
|
|
2043
|
+
"""
|
|
2044
|
+
# The event is the class name, except the last "WebFilter"
|
|
2045
|
+
event = type(self).__name__[:-9]
|
|
2046
|
+
|
|
2047
|
+
return_data = data.copy()
|
|
2048
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
2049
|
+
|
|
2050
|
+
if webfilters:
|
|
2051
|
+
logger.info(f"Webfilter for {event} event.")
|
|
2052
|
+
|
|
2053
|
+
content, exceptions = _process_filter(webfilters=webfilters,
|
|
2054
|
+
data=data,
|
|
2055
|
+
exception=ORASubmissionViewRenderStarted.RenderInvalidTemplate)
|
|
2056
|
+
|
|
2057
|
+
return_data.update(content)
|
|
2058
|
+
|
|
2059
|
+
_check_for_exception(exceptions, ORASubmissionViewRenderStarted.RenderInvalidTemplate)
|
|
2060
|
+
|
|
2061
|
+
return return_data
|
|
2062
|
+
|
|
2063
|
+
return {}
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
class IDVPageURLRequestedWebFilter(PipelineStep):
|
|
2067
|
+
"""
|
|
2068
|
+
Filter used to act on ID verification page URL requests.
|
|
2069
|
+
|
|
2070
|
+
Purpose:
|
|
2071
|
+
This filter is triggered when a user requests to view the ID verification page,
|
|
2072
|
+
just before the page is rendered
|
|
2073
|
+
allowing the filter to act on the URL of the page.
|
|
2074
|
+
|
|
2075
|
+
Filter Type:
|
|
2076
|
+
org.openedx.learning.idv.page.url.requested.v1
|
|
2077
|
+
|
|
2078
|
+
Trigger:
|
|
2079
|
+
- Repository: openedx/edx-platform
|
|
2080
|
+
- Path: lms/djangoapps/verify_student/services.py
|
|
2081
|
+
- Function or Method: XBlockVerificationService.get_verify_location
|
|
2082
|
+
"""
|
|
2083
|
+
|
|
2084
|
+
def run_filter(self, **data):
|
|
2085
|
+
"""
|
|
2086
|
+
:param data = url: str.
|
|
2087
|
+
"""
|
|
2088
|
+
# The event is the class name, except the last "WebFilter"
|
|
2089
|
+
event = type(self).__name__[:-9]
|
|
2090
|
+
|
|
2091
|
+
return_data = data.copy()
|
|
2092
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
2093
|
+
|
|
2094
|
+
if webfilters:
|
|
2095
|
+
logger.info(f"Webfilter for {event} event.")
|
|
2096
|
+
|
|
2097
|
+
content, _ = _process_filter(webfilters=webfilters, data=data)
|
|
2098
|
+
|
|
2099
|
+
return_data.update(content)
|
|
2100
|
+
|
|
2101
|
+
return return_data
|
|
2102
|
+
|
|
2103
|
+
return {}
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
class CourseAboutPageURLRequestedWebFilter(PipelineStep):
|
|
2107
|
+
"""
|
|
2108
|
+
Filter used to act on course about page URL requests.
|
|
2109
|
+
|
|
2110
|
+
Purpose:
|
|
2111
|
+
This filter is triggered when a user requests to view the course about page, just before the page is rendered
|
|
2112
|
+
allowing the filter to act on the URL of the page and the course org.
|
|
2113
|
+
|
|
2114
|
+
Filter Type:
|
|
2115
|
+
org.openedx.learning.course_about.page.url.requested.v1
|
|
2116
|
+
|
|
2117
|
+
Trigger:
|
|
2118
|
+
- Repository: openedx/edx-platform
|
|
2119
|
+
- Path: common/djangoapps/util/course.py
|
|
2120
|
+
- Function or Method: get_link_for_about_page
|
|
2121
|
+
"""
|
|
2122
|
+
|
|
2123
|
+
def run_filter(self, **data):
|
|
2124
|
+
"""
|
|
2125
|
+
:param data = url: str, org: str.
|
|
2126
|
+
"""
|
|
2127
|
+
# The event is the class name, except the last "WebFilter"
|
|
2128
|
+
event = type(self).__name__[:-9]
|
|
2129
|
+
|
|
2130
|
+
return_data = data.copy()
|
|
2131
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
2132
|
+
|
|
2133
|
+
if webfilters:
|
|
2134
|
+
logger.info(f"Webfilter for {event} event.")
|
|
2135
|
+
|
|
2136
|
+
content, _ = _process_filter(webfilters=webfilters, data=data)
|
|
2137
|
+
|
|
2138
|
+
return_data.update(content)
|
|
2139
|
+
|
|
2140
|
+
return return_data
|
|
2141
|
+
|
|
2142
|
+
return {}
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
class ScheduleQuerySetRequestedWebFilter(PipelineStep):
|
|
2146
|
+
"""
|
|
2147
|
+
Filter used to apply additional filtering to a given QuerySet of Schedules.
|
|
2148
|
+
|
|
2149
|
+
Purpose:
|
|
2150
|
+
This filter is triggered when a QuerySet of Schedules is requested,
|
|
2151
|
+
allowing the filter to act on the schedules
|
|
2152
|
+
data. If you want to know more about the Schedules feature, please refer to the
|
|
2153
|
+
`official documentation
|
|
2154
|
+
<https://github.com/openedx/edx-platform/tree/master/openedx/core/djangoapps/schedules#readme>`_
|
|
2155
|
+
|
|
2156
|
+
Filter Type:
|
|
2157
|
+
org.openedx.learning.schedule.queryset.requested.v1
|
|
2158
|
+
|
|
2159
|
+
Trigger:
|
|
2160
|
+
- Repository: openedx/edx-platform
|
|
2161
|
+
- Path: openedx/core/djangoapps/schedules/resolvers.py
|
|
2162
|
+
- Function or Method: BinnedSchedulesBaseResolver.get_schedules_with_target_date_by_bin_and_orgs
|
|
2163
|
+
"""
|
|
2164
|
+
|
|
2165
|
+
def run_filter(self, **data):
|
|
2166
|
+
"""
|
|
2167
|
+
:param data = schedules: QuerySet.
|
|
2168
|
+
"""
|
|
2169
|
+
# The event is the class name, except the last "WebFilter"
|
|
2170
|
+
event = type(self).__name__[:-9]
|
|
2171
|
+
|
|
2172
|
+
return_data = data.copy()
|
|
2173
|
+
|
|
2174
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
2175
|
+
|
|
2176
|
+
if webfilters:
|
|
2177
|
+
logger.info(f"Webfilter for {event} event.")
|
|
2178
|
+
|
|
2179
|
+
payload = {'schedules': list(data['schedules'].values())}
|
|
2180
|
+
content, _ = _process_filter(webfilters=webfilters, data=payload)
|
|
2181
|
+
|
|
2182
|
+
return_data['schedules'] = data['schedules'].filter(**content.get('filter', {}))
|
|
2183
|
+
|
|
2184
|
+
return return_data
|
|
2185
|
+
|
|
2186
|
+
return {}
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
class LMSPageURLRequestedWebFilter(PipelineStep):
|
|
2190
|
+
"""
|
|
2191
|
+
Filter used to modify the URL of the page requested by the user.
|
|
2192
|
+
|
|
2193
|
+
Purpose:
|
|
2194
|
+
This filter is triggered when a user loads a page in Studio that references an LMS page,
|
|
2195
|
+
allowing the filter to
|
|
2196
|
+
modify the URL of the page requested by the user.
|
|
2197
|
+
|
|
2198
|
+
Filter Type:
|
|
2199
|
+
org.openedx.content_authoring.lms.page.url.requested.v1
|
|
2200
|
+
|
|
2201
|
+
Trigger:
|
|
2202
|
+
- Repository: openedx/edx-platform
|
|
2203
|
+
- Path: cms/djangoapps/contentstore/asset_storage_handler.py
|
|
2204
|
+
- Function or Method: get_asset_json
|
|
2205
|
+
"""
|
|
2206
|
+
|
|
2207
|
+
def run_filter(self, **data):
|
|
2208
|
+
"""
|
|
2209
|
+
:param data = url: str, org: str.
|
|
2210
|
+
"""
|
|
2211
|
+
# The event is the class name, except the last "WebFilter"
|
|
2212
|
+
event = type(self).__name__[:-9]
|
|
2213
|
+
|
|
2214
|
+
return_data = data.copy()
|
|
2215
|
+
|
|
2216
|
+
webfilters = Webfilter.objects.filter(enabled=True, event=event)
|
|
2217
|
+
|
|
2218
|
+
if webfilters:
|
|
2219
|
+
logger.info(f"Webfilter for {event} event.")
|
|
2220
|
+
|
|
2221
|
+
content, _ = _process_filter(webfilters=webfilters, data=data)
|
|
2222
|
+
|
|
2223
|
+
return_data.update(content)
|
|
2224
|
+
|
|
2225
|
+
return return_data
|
|
2226
|
+
|
|
2227
|
+
return {}
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
class InstructorDashboardTabsRequestedWebFilter(PipelineStep):
|
|
2231
|
+
"""Customize Verawood instructor dashboard tabs, preserving user and course objects."""
|
|
2232
|
+
|
|
2233
|
+
def run_filter(self, tabs, user, course_key): # pylint: disable=arguments-differ
|
|
2234
|
+
"""Accept a replacement tabs list or PreventTabsGeneration exception."""
|
|
2235
|
+
webfilters = Webfilter.objects.filter(enabled=True, event='InstructorDashboardTabsRequested')
|
|
2236
|
+
if not webfilters:
|
|
2237
|
+
return {}
|
|
2238
|
+
content, exceptions = _process_filter(
|
|
2239
|
+
webfilters, {'tabs': tabs, 'user': user, 'course_key': course_key},
|
|
2240
|
+
exception=InstructorDashboardTabsRequested.PreventTabsGeneration,
|
|
2241
|
+
)
|
|
2242
|
+
_check_for_exception(exceptions, InstructorDashboardTabsRequested.PreventTabsGeneration)
|
|
2243
|
+
updated_tabs = content.get('tabs', tabs)
|
|
2244
|
+
if not isinstance(updated_tabs, list) or not all(isinstance(tab, dict) for tab in updated_tabs):
|
|
2245
|
+
raise ValueError('tabs must be a list of objects')
|
|
2246
|
+
return {'tabs': updated_tabs, 'user': user, 'course_key': course_key}
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
class AccountSettingsReadOnlyFieldsRequestedWebFilter(PipelineStep):
|
|
2250
|
+
"""Add protected account fields without removing existing read-only restrictions."""
|
|
2251
|
+
|
|
2252
|
+
def run_filter(self, readonly_fields, user): # pylint: disable=arguments-differ
|
|
2253
|
+
"""Convert returned JSON field names into the set required by the account API."""
|
|
2254
|
+
webfilters = Webfilter.objects.filter(enabled=True, event='AccountSettingsReadOnlyFieldsRequested')
|
|
2255
|
+
if not webfilters:
|
|
2256
|
+
return {}
|
|
2257
|
+
content, _ = _process_filter(webfilters, {'readonly_fields': sorted(readonly_fields), 'user': user})
|
|
2258
|
+
additional_fields = content.get('readonly_fields', [])
|
|
2259
|
+
if not isinstance(additional_fields, list) or not all(isinstance(field, str) for field in additional_fields):
|
|
2260
|
+
raise ValueError('readonly_fields must be a list of strings')
|
|
2261
|
+
return {'readonly_fields': readonly_fields | set(additional_fields), 'user': user}
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
class GradeEventContextRequestedWebFilter(PipelineStep):
|
|
2265
|
+
"""Enrich grade event context while retaining the platform's user and course IDs."""
|
|
2266
|
+
|
|
2267
|
+
def run_filter(self, context, user_id, course_id): # pylint: disable=arguments-differ
|
|
2268
|
+
"""Merge additional context from configured endpoints."""
|
|
2269
|
+
webfilters = Webfilter.objects.filter(enabled=True, event='GradeEventContextRequested')
|
|
2270
|
+
if not webfilters:
|
|
2271
|
+
return {}
|
|
2272
|
+
content, _ = _process_filter(webfilters, {'context': context, 'user_id': user_id, 'course_id': course_id})
|
|
2273
|
+
extra_context = content.get('context', {})
|
|
2274
|
+
if not isinstance(extra_context, dict):
|
|
2275
|
+
raise ValueError('context must be an object')
|
|
2276
|
+
return {'context': {**context, **extra_context}, 'user_id': user_id, 'course_id': course_id}
|