openedx-plugin-sample 3.0.1__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/PKG-INFO +48 -22
  2. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/README.md +47 -21
  3. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/apps.py +2 -2
  4. openedx_plugin_sample-3.1.0/src/openedx_plugin_sample/pipeline.py +90 -0
  5. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/settings/common.py +4 -3
  6. openedx_plugin_sample-3.1.0/src/openedx_plugin_sample/signals.py +66 -0
  7. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample.egg-info/PKG-INFO +48 -22
  8. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample.egg-info/SOURCES.txt +2 -0
  9. openedx_plugin_sample-3.1.0/tests/test_pipeline.py +101 -0
  10. openedx_plugin_sample-3.1.0/tests/test_signals.py +77 -0
  11. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/uv.lock +56 -56
  12. openedx_plugin_sample-3.0.1/src/openedx_plugin_sample/pipeline.py +0 -156
  13. openedx_plugin_sample-3.0.1/src/openedx_plugin_sample/signals.py +0 -132
  14. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/.annotation_safe_list.yml +0 -0
  15. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/.coveragerc +0 -0
  16. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/.editorconfig +0 -0
  17. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/.gitignore +0 -0
  18. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/.pii_annotations.yml +0 -0
  19. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/.readthedocs.yaml +0 -0
  20. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/LICENSE.txt +0 -0
  21. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/Makefile +0 -0
  22. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/codecov.yml +0 -0
  23. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/Makefile +0 -0
  24. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/_static/theme_overrides.css +0 -0
  25. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/concepts/index.rst +0 -0
  26. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/conf.py +0 -0
  27. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/decisions/0001-purpose-of-this-repo.rst +0 -0
  28. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/decisions/README.rst +0 -0
  29. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/decisions.rst +0 -0
  30. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/getting_started.rst +0 -0
  31. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/how-tos/index.rst +0 -0
  32. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/index.rst +0 -0
  33. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/internationalization.rst +0 -0
  34. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/make.bat +0 -0
  35. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/openedx_plugin_sample.rst +0 -0
  36. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/quickstarts/index.rst +0 -0
  37. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/references/index.rst +0 -0
  38. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/docs/testing.rst +0 -0
  39. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/manage.py +0 -0
  40. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/pylintrc +0 -0
  41. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/pylintrc_tweaks +0 -0
  42. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/pyproject.toml +0 -0
  43. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/setup.cfg +0 -0
  44. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/__init__.py +0 -0
  45. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/conf/locale/config.yaml +0 -0
  46. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/migrations/0001_initial.py +0 -0
  47. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/migrations/__init__.py +0 -0
  48. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/models.py +0 -0
  49. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/py.typed +0 -0
  50. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/serializers.py +0 -0
  51. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/settings/production.py +0 -0
  52. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/settings/test.py +0 -0
  53. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/templates/openedx_plugin_sample/base.html +0 -0
  54. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/urls.py +0 -0
  55. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample/views.py +0 -0
  56. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample.egg-info/dependency_links.txt +0 -0
  57. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample.egg-info/entry_points.txt +0 -0
  58. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample.egg-info/requires.txt +0 -0
  59. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/src/openedx_plugin_sample.egg-info/top_level.txt +0 -0
  60. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/test_settings.py +0 -0
  61. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/test_utils/__init__.py +0 -0
  62. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/tests/test_api.py +0 -0
  63. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/tests/test_models.py +0 -0
  64. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/tests/test_plugin_integration.py +0 -0
  65. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/tests/urls.py +0 -0
  66. {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.1.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openedx-plugin-sample
3
- Version: 3.0.1
3
+ Version: 3.1.0
4
4
  Summary: A sample backend plugin for the Open edX Platform
5
5
  Author-email: Open edX Project <oscm@openedx.org>
6
6
  License-Expression: Apache-2.0
@@ -212,40 +212,62 @@ def perform_create(self, serializer):
212
212
 
213
213
  ### Event Handler Example
214
214
 
215
+ This plugin reacts to `COURSE_ENROLLMENT_CHANGED` to unarchive a course on the
216
+ learner's dashboard when they upgrade to the verified track. The idea: a learner
217
+ who has previously archived a course shouldn't have to dig it back out of their
218
+ "Archived" section after upgrading -- their renewed investment is a strong
219
+ signal that the course belongs back in their active list.
220
+
215
221
  ```python
216
- from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED
222
+ from openedx_events.learning.data import CourseEnrollmentData
223
+ from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED
217
224
  from django.dispatch import receiver
218
225
 
219
- @receiver(COURSE_CATALOG_INFO_CHANGED)
220
- def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs):
221
- logging.info(f"{catalog_info.course_key} has been updated!")
222
- # Add your custom business logic here
226
+ @receiver(COURSE_ENROLLMENT_CHANGED)
227
+ def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
228
+ if not enrollment.is_active or enrollment.mode != "verified":
229
+ return
230
+ CourseArchiveStatus.objects.filter(
231
+ user_id=enrollment.user.id,
232
+ course_id=enrollment.course.course_key,
233
+ is_archived=True,
234
+ ).update(is_archived=False, archive_date=None)
223
235
  ```
224
236
 
237
+ **Why an event (not a filter)?** The unarchive is a *one-time nudge*: if the
238
+ learner re-archives the course later, we respect that. Implementing this as a
239
+ continuous rule in the filter pipeline (e.g. "any verified course is never
240
+ archived") would override the learner's intent. Events fire at the moment a
241
+ state change happens, which is exactly when this kind of one-shot reaction
242
+ belongs.
243
+
225
244
  ### Available Events
226
245
 
227
246
  **Event Catalog**: [Open edX Events Reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html)
228
247
 
229
248
  **Common Events:**
230
- - `COURSE_CATALOG_INFO_CHANGED` - Course information updated
249
+ - `COURSE_ENROLLMENT_CHANGED` - Enrollment becomes active/inactive or changes mode
250
+ - `COURSE_ENROLLMENT_CREATED` - Student newly enrolled in a course
231
251
  - `STUDENT_REGISTRATION_COMPLETED` - New user registered
232
252
  - `CERTIFICATE_CREATED` - Certificate generated for learner
233
- - `ENROLLMENT_CREATED` - Student enrolled in course
253
+ - `COURSE_CATALOG_INFO_CHANGED` - Course catalog metadata updated
234
254
 
235
255
  ### Event Data Structure
236
256
 
237
- Each event includes specific data. For `COURSE_CATALOG_INFO_CHANGED`:
257
+ Each event includes a specific data object. For `COURSE_ENROLLMENT_CHANGED`:
238
258
 
239
259
  ```python
240
- def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs):
241
- # catalog_info contains:
242
- # - course_key: CourseKey object
243
- # - name: Course display name
244
- # - schedule: Course schedule information
245
- # - hidden: Visibility status
260
+ def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
261
+ # enrollment contains:
262
+ # - user: UserData (with .id, .is_active, .pii)
263
+ # - course: CourseData (with .course_key, .display_name, .start, .end)
264
+ # - mode: str (e.g. "audit", "verified", "honor")
265
+ # - is_active: bool
266
+ # - creation_date: datetime
267
+ # - created_by: UserData (optional)
246
268
  ```
247
269
 
248
- **Key Point**: Check the [event definition](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html) to understand what data is available.
270
+ **Key Point**: Check the [event data reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html) to understand the exact fields available for each event.
249
271
 
250
272
  ### Signal Handler Registration
251
273
 
@@ -499,15 +521,19 @@ const response = await client.get(
499
521
  );
500
522
  ```
501
523
 
502
- ### Events + API Integration
524
+ ### Events + Models Integration
503
525
 
504
526
  ```python
505
- @receiver(COURSE_CATALOG_INFO_CHANGED)
506
- def sync_course_archive_on_change(signal, sender, catalog_info, **kwargs):
507
- # Update archive statuses when course info changes
527
+ @receiver(COURSE_ENROLLMENT_CHANGED)
528
+ def unarchive_on_verified_upgrade(signal, sender, enrollment, **kwargs):
529
+ # React to a verified upgrade by clearing the learner's archive flag
530
+ if not enrollment.is_active or enrollment.mode != "verified":
531
+ return
508
532
  CourseArchiveStatus.objects.filter(
509
- course_id=catalog_info.course_key
510
- ).update(last_synced=timezone.now())
533
+ user_id=enrollment.user.id,
534
+ course_id=enrollment.course.course_key,
535
+ is_archived=True,
536
+ ).update(is_archived=False, archive_date=None)
511
537
  ```
512
538
 
513
539
  ### Filters + Settings Integration
@@ -184,40 +184,62 @@ def perform_create(self, serializer):
184
184
 
185
185
  ### Event Handler Example
186
186
 
187
+ This plugin reacts to `COURSE_ENROLLMENT_CHANGED` to unarchive a course on the
188
+ learner's dashboard when they upgrade to the verified track. The idea: a learner
189
+ who has previously archived a course shouldn't have to dig it back out of their
190
+ "Archived" section after upgrading -- their renewed investment is a strong
191
+ signal that the course belongs back in their active list.
192
+
187
193
  ```python
188
- from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED
194
+ from openedx_events.learning.data import CourseEnrollmentData
195
+ from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED
189
196
  from django.dispatch import receiver
190
197
 
191
- @receiver(COURSE_CATALOG_INFO_CHANGED)
192
- def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs):
193
- logging.info(f"{catalog_info.course_key} has been updated!")
194
- # Add your custom business logic here
198
+ @receiver(COURSE_ENROLLMENT_CHANGED)
199
+ def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
200
+ if not enrollment.is_active or enrollment.mode != "verified":
201
+ return
202
+ CourseArchiveStatus.objects.filter(
203
+ user_id=enrollment.user.id,
204
+ course_id=enrollment.course.course_key,
205
+ is_archived=True,
206
+ ).update(is_archived=False, archive_date=None)
195
207
  ```
196
208
 
209
+ **Why an event (not a filter)?** The unarchive is a *one-time nudge*: if the
210
+ learner re-archives the course later, we respect that. Implementing this as a
211
+ continuous rule in the filter pipeline (e.g. "any verified course is never
212
+ archived") would override the learner's intent. Events fire at the moment a
213
+ state change happens, which is exactly when this kind of one-shot reaction
214
+ belongs.
215
+
197
216
  ### Available Events
198
217
 
199
218
  **Event Catalog**: [Open edX Events Reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html)
200
219
 
201
220
  **Common Events:**
202
- - `COURSE_CATALOG_INFO_CHANGED` - Course information updated
221
+ - `COURSE_ENROLLMENT_CHANGED` - Enrollment becomes active/inactive or changes mode
222
+ - `COURSE_ENROLLMENT_CREATED` - Student newly enrolled in a course
203
223
  - `STUDENT_REGISTRATION_COMPLETED` - New user registered
204
224
  - `CERTIFICATE_CREATED` - Certificate generated for learner
205
- - `ENROLLMENT_CREATED` - Student enrolled in course
225
+ - `COURSE_CATALOG_INFO_CHANGED` - Course catalog metadata updated
206
226
 
207
227
  ### Event Data Structure
208
228
 
209
- Each event includes specific data. For `COURSE_CATALOG_INFO_CHANGED`:
229
+ Each event includes a specific data object. For `COURSE_ENROLLMENT_CHANGED`:
210
230
 
211
231
  ```python
212
- def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs):
213
- # catalog_info contains:
214
- # - course_key: CourseKey object
215
- # - name: Course display name
216
- # - schedule: Course schedule information
217
- # - hidden: Visibility status
232
+ def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
233
+ # enrollment contains:
234
+ # - user: UserData (with .id, .is_active, .pii)
235
+ # - course: CourseData (with .course_key, .display_name, .start, .end)
236
+ # - mode: str (e.g. "audit", "verified", "honor")
237
+ # - is_active: bool
238
+ # - creation_date: datetime
239
+ # - created_by: UserData (optional)
218
240
  ```
219
241
 
220
- **Key Point**: Check the [event definition](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html) to understand what data is available.
242
+ **Key Point**: Check the [event data reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html) to understand the exact fields available for each event.
221
243
 
222
244
  ### Signal Handler Registration
223
245
 
@@ -471,15 +493,19 @@ const response = await client.get(
471
493
  );
472
494
  ```
473
495
 
474
- ### Events + API Integration
496
+ ### Events + Models Integration
475
497
 
476
498
  ```python
477
- @receiver(COURSE_CATALOG_INFO_CHANGED)
478
- def sync_course_archive_on_change(signal, sender, catalog_info, **kwargs):
479
- # Update archive statuses when course info changes
499
+ @receiver(COURSE_ENROLLMENT_CHANGED)
500
+ def unarchive_on_verified_upgrade(signal, sender, enrollment, **kwargs):
501
+ # React to a verified upgrade by clearing the learner's archive flag
502
+ if not enrollment.is_active or enrollment.mode != "verified":
503
+ return
480
504
  CourseArchiveStatus.objects.filter(
481
- course_id=catalog_info.course_key
482
- ).update(last_synced=timezone.now())
505
+ user_id=enrollment.user.id,
506
+ course_id=enrollment.course.course_key,
507
+ is_archived=True,
508
+ ).update(is_archived=False, archive_date=None)
483
509
  ```
484
510
 
485
511
  ### Filters + Settings Integration
@@ -92,8 +92,8 @@ class SamplePluginConfig(AppConfig):
92
92
  # "lms.djangoapp": {
93
93
  # "relative_path": "signals",
94
94
  # "receivers": [{
95
- # "receiver_func_name": "log_course_info_changed",
96
- # "signal_path": "openedx_events.content_authoring.signals.COURSE_CATALOG_INFO_CHANGED",
95
+ # "receiver_func_name": "unarchive_on_verified_upgrade",
96
+ # "signal_path": "openedx_events.learning.signals.COURSE_ENROLLMENT_CHANGED",
97
97
  # }]
98
98
  # }
99
99
  # }
@@ -0,0 +1,90 @@
1
+ """
2
+ Open edX Filters implementation for the openedx_plugin_sample application.
3
+
4
+ This module demonstrates how to use Open edX Filters to modify platform behavior
5
+ without changing core code. Filters are part of the Hooks Extension Framework
6
+ and allow you to intercept and modify data at specific points in the platform.
7
+
8
+ What Are Open edX Filters?
9
+ Filters are functions that can modify application behavior by altering input data
10
+ or halting execution based on specific conditions. Unlike events (which only
11
+ observe), filters can change what happens next in the platform.
12
+
13
+ Key Concepts:
14
+ - Filters receive data and return modified data
15
+ - They run at specific pipeline steps during platform operations
16
+ - Filters can halt execution by raising exceptions
17
+ - Multiple filters can be chained together in a pipeline
18
+ - Filters should be lightweight and handle errors gracefully
19
+
20
+ Official Documentation:
21
+ - Filters Overview: https://docs.openedx.org/projects/openedx-filters/en/latest/
22
+ - Using Filters: https://docs.openedx.org/projects/openedx-filters/en/latest/how-tos/using-filters.html
23
+ - Available Filters: https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters.html
24
+ - Filter Tooling: https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters-tooling.html
25
+
26
+ Registration Process:
27
+ 1. Create filter class inheriting from PipelineStep
28
+ 2. Implement run_filter() method with correct signature
29
+ 3. Register filter in Django settings OPEN_EDX_FILTERS_CONFIG
30
+ 4. Deploy and test the filter behavior
31
+
32
+ Common Use Cases:
33
+ - URL redirection and customization
34
+ - Access control and permission checks
35
+ - Data transformation and validation
36
+ - Integration with external systems
37
+ - Custom business logic implementation
38
+ """ # pylint: disable=line-too-long
39
+
40
+ import logging
41
+
42
+ import crum
43
+ from openedx_filters.filters import PipelineStep
44
+
45
+ from .models import CourseArchiveStatus
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ class AddArchiveStatusToLearnerHomeCourseRun(PipelineStep):
51
+ """
52
+ Customize each courseRun within a Learner Dashboard's /init API response to include the CourseArchiveStatus.
53
+ """ # noqa: E501
54
+
55
+ def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=arguments-differ
56
+ """
57
+ Insert `isArchivedByLearner` into one serialized courseRun for the Learner Home /init response.
58
+
59
+ Args:
60
+ serialized_courserun (dict): One courseRun from the serializer. Reads
61
+ `courseId` (a course key string, e.g. "course-v1:edX+DemoX+Demo_Course");
62
+ all other fields are passed through unchanged.
63
+
64
+ Returns:
65
+ dict: ``{"serialized_courserun": <updated dict>}``. The updated dict has the
66
+ same keys as the input plus `isArchivedByLearner` (bool) -- True iff a
67
+ CourseArchiveStatus row exists for the current request user and this
68
+ courseId with `is_archived=True`; False otherwise (including when no row
69
+ exists).
70
+
71
+ The current user is read from the active request via `crum`, so this filter only
72
+ runs meaningfully inside a request cycle. Note that `isArchivedByLearner` is
73
+ distinct from `isArchived`, which the platform sets based on whether the course
74
+ run itself has ended.
75
+ """ # noqa: E501
76
+ request = crum.get_current_request()
77
+ if not (request and request.user):
78
+ return serialized_courserun
79
+ try:
80
+ is_archived_by_learner = CourseArchiveStatus.objects.get(
81
+ user=request.user, course_id=serialized_courserun["courseId"]
82
+ ).is_archived
83
+ except CourseArchiveStatus.DoesNotExist:
84
+ is_archived_by_learner = False
85
+ return {
86
+ "serialized_courserun": {
87
+ **serialized_courserun,
88
+ "isArchivedByLearner": is_archived_by_learner,
89
+ },
90
+ }
@@ -11,7 +11,8 @@ the main settings object. You can modify this object to add plugin-specific
11
11
  configuration that integrates seamlessly with the platform.
12
12
 
13
13
  Official Documentation:
14
- - Plugin Settings: https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html#plugin-settings
14
+ - Plugin Settings:
15
+ https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html#plugin-settings
15
16
  - Django Settings: https://docs.djangoproject.com/en/stable/topics/settings/
16
17
 
17
18
  Settings Organization:
@@ -96,8 +97,8 @@ def _configure_openedx_filters(settings):
96
97
  filters_config = getattr(settings, 'OPEN_EDX_FILTERS_CONFIG', {})
97
98
 
98
99
  # Filter we want to register
99
- filter_name = "org.openedx.learning.course_about.page.url.requested.v1"
100
- our_pipeline_step = "openedx_plugin_sample.pipeline.ChangeCourseAboutPageUrl"
100
+ filter_name = "org.openedx.learning.home.courserun.api.rendered.started.v1"
101
+ our_pipeline_step = "openedx_plugin_sample.pipeline.AddArchiveStatusToLearnerHomeCourseRun"
101
102
 
102
103
  # Check if this filter already has configuration
103
104
  if filter_name in filters_config:
@@ -0,0 +1,66 @@
1
+ """
2
+ Open edX Events signal handlers for the openedx_plugin_sample application.
3
+
4
+ This module demonstrates how to consume Open edX Events to react to platform
5
+ activity. Events are part of the Hooks Extension Framework and provide a
6
+ stable way to extend Open edX without modifying core code.
7
+
8
+ Key Concepts:
9
+ - Events are fired at specific points in the platform lifecycle
10
+ - Each event delivers a structured data object (defined in openedx-events)
11
+ - Event handlers can take action but cannot modify the event payload
12
+ - Handlers must be imported from apps.py ready() so @receiver registers them
13
+
14
+ Official Documentation:
15
+ - Events Overview: https://docs.openedx.org/projects/openedx-events/en/latest/
16
+ - Available Events: https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html
17
+ - Consuming Events: https://docs.openedx.org/projects/openedx-events/en/latest/how-tos/consume-an-event.html
18
+ - Event Data Objects: https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html
19
+ """
20
+
21
+ import logging
22
+
23
+ from django.dispatch import receiver
24
+ from openedx_events.learning.data import CourseEnrollmentData
25
+ from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED
26
+
27
+ from .models import CourseArchiveStatus
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @receiver(COURSE_ENROLLMENT_CHANGED)
33
+ def unarchive_on_verified_upgrade(
34
+ signal, sender, enrollment: CourseEnrollmentData, **kwargs
35
+ ): # pylint: disable=unused-argument
36
+ """
37
+ Unarchive a course on the learner's dashboard when they upgrade to verified.
38
+
39
+ If a learner has previously archived a course (CourseArchiveStatus.is_archived=True)
40
+ and then upgrades to the verified track, the course shouldn't stay tucked away
41
+ in their "Archived" section -- their renewed investment in the course is a
42
+ strong signal that they want it back in their active list.
43
+
44
+ This is intentionally a one-time nudge, not a continuous rule: if the learner
45
+ re-archives the course later, we respect that choice. That's why we react to
46
+ the enrollment-change *event* rather than computing `isArchivedByLearner`
47
+ from enrollment mode in the filter pipeline.
48
+
49
+ Event reference:
50
+ https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html#openedx_events.learning.signals.COURSE_ENROLLMENT_CHANGED
51
+ """
52
+ if not enrollment.is_active or enrollment.mode != "verified":
53
+ return
54
+
55
+ updated = CourseArchiveStatus.objects.filter(
56
+ user_id=enrollment.user.id,
57
+ course_id=enrollment.course.course_key,
58
+ is_archived=True,
59
+ ).update(is_archived=False, archive_date=None)
60
+
61
+ if updated:
62
+ logger.info(
63
+ "Unarchived course %s for user %s after verified upgrade",
64
+ enrollment.course.course_key,
65
+ enrollment.user.id,
66
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openedx-plugin-sample
3
- Version: 3.0.1
3
+ Version: 3.1.0
4
4
  Summary: A sample backend plugin for the Open edX Platform
5
5
  Author-email: Open edX Project <oscm@openedx.org>
6
6
  License-Expression: Apache-2.0
@@ -212,40 +212,62 @@ def perform_create(self, serializer):
212
212
 
213
213
  ### Event Handler Example
214
214
 
215
+ This plugin reacts to `COURSE_ENROLLMENT_CHANGED` to unarchive a course on the
216
+ learner's dashboard when they upgrade to the verified track. The idea: a learner
217
+ who has previously archived a course shouldn't have to dig it back out of their
218
+ "Archived" section after upgrading -- their renewed investment is a strong
219
+ signal that the course belongs back in their active list.
220
+
215
221
  ```python
216
- from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED
222
+ from openedx_events.learning.data import CourseEnrollmentData
223
+ from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED
217
224
  from django.dispatch import receiver
218
225
 
219
- @receiver(COURSE_CATALOG_INFO_CHANGED)
220
- def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs):
221
- logging.info(f"{catalog_info.course_key} has been updated!")
222
- # Add your custom business logic here
226
+ @receiver(COURSE_ENROLLMENT_CHANGED)
227
+ def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
228
+ if not enrollment.is_active or enrollment.mode != "verified":
229
+ return
230
+ CourseArchiveStatus.objects.filter(
231
+ user_id=enrollment.user.id,
232
+ course_id=enrollment.course.course_key,
233
+ is_archived=True,
234
+ ).update(is_archived=False, archive_date=None)
223
235
  ```
224
236
 
237
+ **Why an event (not a filter)?** The unarchive is a *one-time nudge*: if the
238
+ learner re-archives the course later, we respect that. Implementing this as a
239
+ continuous rule in the filter pipeline (e.g. "any verified course is never
240
+ archived") would override the learner's intent. Events fire at the moment a
241
+ state change happens, which is exactly when this kind of one-shot reaction
242
+ belongs.
243
+
225
244
  ### Available Events
226
245
 
227
246
  **Event Catalog**: [Open edX Events Reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html)
228
247
 
229
248
  **Common Events:**
230
- - `COURSE_CATALOG_INFO_CHANGED` - Course information updated
249
+ - `COURSE_ENROLLMENT_CHANGED` - Enrollment becomes active/inactive or changes mode
250
+ - `COURSE_ENROLLMENT_CREATED` - Student newly enrolled in a course
231
251
  - `STUDENT_REGISTRATION_COMPLETED` - New user registered
232
252
  - `CERTIFICATE_CREATED` - Certificate generated for learner
233
- - `ENROLLMENT_CREATED` - Student enrolled in course
253
+ - `COURSE_CATALOG_INFO_CHANGED` - Course catalog metadata updated
234
254
 
235
255
  ### Event Data Structure
236
256
 
237
- Each event includes specific data. For `COURSE_CATALOG_INFO_CHANGED`:
257
+ Each event includes a specific data object. For `COURSE_ENROLLMENT_CHANGED`:
238
258
 
239
259
  ```python
240
- def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs):
241
- # catalog_info contains:
242
- # - course_key: CourseKey object
243
- # - name: Course display name
244
- # - schedule: Course schedule information
245
- # - hidden: Visibility status
260
+ def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
261
+ # enrollment contains:
262
+ # - user: UserData (with .id, .is_active, .pii)
263
+ # - course: CourseData (with .course_key, .display_name, .start, .end)
264
+ # - mode: str (e.g. "audit", "verified", "honor")
265
+ # - is_active: bool
266
+ # - creation_date: datetime
267
+ # - created_by: UserData (optional)
246
268
  ```
247
269
 
248
- **Key Point**: Check the [event definition](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html) to understand what data is available.
270
+ **Key Point**: Check the [event data reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html) to understand the exact fields available for each event.
249
271
 
250
272
  ### Signal Handler Registration
251
273
 
@@ -499,15 +521,19 @@ const response = await client.get(
499
521
  );
500
522
  ```
501
523
 
502
- ### Events + API Integration
524
+ ### Events + Models Integration
503
525
 
504
526
  ```python
505
- @receiver(COURSE_CATALOG_INFO_CHANGED)
506
- def sync_course_archive_on_change(signal, sender, catalog_info, **kwargs):
507
- # Update archive statuses when course info changes
527
+ @receiver(COURSE_ENROLLMENT_CHANGED)
528
+ def unarchive_on_verified_upgrade(signal, sender, enrollment, **kwargs):
529
+ # React to a verified upgrade by clearing the learner's archive flag
530
+ if not enrollment.is_active or enrollment.mode != "verified":
531
+ return
508
532
  CourseArchiveStatus.objects.filter(
509
- course_id=catalog_info.course_key
510
- ).update(last_synced=timezone.now())
533
+ user_id=enrollment.user.id,
534
+ course_id=enrollment.course.course_key,
535
+ is_archived=True,
536
+ ).update(is_archived=False, archive_date=None)
511
537
  ```
512
538
 
513
539
  ### Filters + Settings Integration
@@ -57,5 +57,7 @@ src/openedx_plugin_sample/templates/openedx_plugin_sample/base.html
57
57
  test_utils/__init__.py
58
58
  tests/test_api.py
59
59
  tests/test_models.py
60
+ tests/test_pipeline.py
60
61
  tests/test_plugin_integration.py
62
+ tests/test_signals.py
61
63
  tests/urls.py
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python
2
+ # pylint: disable=redefined-outer-name
3
+ """
4
+ Tests for the `sample-plugin` Open edX Filters pipeline steps.
5
+ """
6
+
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+ from django.contrib.auth import get_user_model
11
+ from opaque_keys.edx.keys import CourseKey
12
+
13
+ from openedx_plugin_sample.models import CourseArchiveStatus
14
+ from openedx_plugin_sample.pipeline import AddArchiveStatusToLearnerHomeCourseRun
15
+
16
+ User = get_user_model()
17
+
18
+
19
+ @pytest.fixture
20
+ def user():
21
+ """
22
+ Create and return a test user.
23
+ """
24
+ return User.objects.create_user(
25
+ username="testuser", email="testuser@example.com", password="password123"
26
+ )
27
+
28
+
29
+ @pytest.fixture
30
+ def course_key():
31
+ """
32
+ Create and return a test course key.
33
+ """
34
+ return CourseKey.from_string("course-v1:edX+DemoX+Demo_Course")
35
+
36
+
37
+ @pytest.fixture
38
+ def serialized_courserun(course_key):
39
+ """
40
+ Return a minimal courseRun dict like the learner home /init API would emit.
41
+ """
42
+ return {
43
+ "courseId": str(course_key),
44
+ "courseNumber": "DemoX",
45
+ }
46
+
47
+
48
+ @pytest.fixture
49
+ def mock_current_request(user):
50
+ """
51
+ Patch crum.get_current_request so the filter sees `user` as the requester.
52
+
53
+ The filter relies on `crum` to find the current user, which is set by middleware
54
+ in a real request cycle. In unit tests we stub it directly.
55
+ """
56
+ request = MagicMock()
57
+ request.user = user
58
+ with patch(
59
+ "openedx_plugin_sample.pipeline.crum.get_current_request",
60
+ return_value=request,
61
+ ):
62
+ yield request
63
+
64
+
65
+ @pytest.mark.django_db
66
+ def test_archived_courserun_gets_is_archived_by_learner_true(
67
+ user, course_key, serialized_courserun, mock_current_request # pylint: disable=unused-argument
68
+ ):
69
+ """
70
+ Test that the filter adds isArchivedByLearner=True when the learner has
71
+ archived this course.
72
+ """
73
+ CourseArchiveStatus.objects.create(
74
+ course_id=course_key, user=user, is_archived=True
75
+ )
76
+
77
+ result = AddArchiveStatusToLearnerHomeCourseRun(
78
+ filter_type="org.openedx.learning.home.courserun.api.rendering.started.v1",
79
+ running_pipeline=[],
80
+ ).run_filter(serialized_courserun=serialized_courserun)
81
+
82
+ assert result["serialized_courserun"]["isArchivedByLearner"] is True
83
+ # Existing fields on the courseRun are preserved.
84
+ assert result["serialized_courserun"]["courseId"] == str(course_key)
85
+ assert result["serialized_courserun"]["courseNumber"] == "DemoX"
86
+
87
+
88
+ @pytest.mark.django_db
89
+ def test_courserun_with_no_archive_record_defaults_to_false(
90
+ serialized_courserun, mock_current_request # pylint: disable=unused-argument
91
+ ):
92
+ """
93
+ Test that the filter defaults isArchivedByLearner to False when the learner
94
+ has no CourseArchiveStatus row for the course.
95
+ """
96
+ result = AddArchiveStatusToLearnerHomeCourseRun(
97
+ filter_type="org.openedx.learning.home.courserun.api.rendering.started.v1",
98
+ running_pipeline=[],
99
+ ).run_filter(serialized_courserun=serialized_courserun)
100
+
101
+ assert result["serialized_courserun"]["isArchivedByLearner"] is False