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.
@@ -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": "&copy; 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}