openedx-plugin-sample 3.0.1__tar.gz → 3.2.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.
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/.annotation_safe_list.yml +8 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/PKG-INFO +49 -22
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/README.md +47 -21
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/pyproject.toml +1 -0
- openedx_plugin_sample-3.2.0/src/openedx_plugin_sample/admin.py +55 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/apps.py +2 -2
- openedx_plugin_sample-3.2.0/src/openedx_plugin_sample/migrations/0001_initial.py +36 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/models.py +11 -6
- openedx_plugin_sample-3.2.0/src/openedx_plugin_sample/pipeline.py +91 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/serializers.py +22 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/settings/common.py +4 -3
- openedx_plugin_sample-3.2.0/src/openedx_plugin_sample/signals.py +66 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/views.py +41 -14
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/PKG-INFO +49 -22
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/SOURCES.txt +3 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/requires.txt +1 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/test_settings.py +2 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/tests/test_api.py +40 -16
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/tests/test_models.py +19 -15
- openedx_plugin_sample-3.2.0/tests/test_pipeline.py +113 -0
- openedx_plugin_sample-3.2.0/tests/test_signals.py +89 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/uv.lock +410 -57
- openedx_plugin_sample-3.0.1/src/openedx_plugin_sample/migrations/0001_initial.py +0 -78
- openedx_plugin_sample-3.0.1/src/openedx_plugin_sample/pipeline.py +0 -156
- openedx_plugin_sample-3.0.1/src/openedx_plugin_sample/signals.py +0 -132
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/.coveragerc +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/.editorconfig +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/.gitignore +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/.pii_annotations.yml +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/.readthedocs.yaml +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/LICENSE.txt +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/Makefile +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/codecov.yml +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/Makefile +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/_static/theme_overrides.css +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/concepts/index.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/conf.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/decisions/0001-purpose-of-this-repo.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/decisions/README.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/decisions.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/getting_started.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/how-tos/index.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/index.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/internationalization.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/make.bat +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/openedx_plugin_sample.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/quickstarts/index.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/references/index.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/docs/testing.rst +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/manage.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/pylintrc +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/pylintrc_tweaks +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/setup.cfg +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/__init__.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/conf/locale/config.yaml +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/migrations/__init__.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/py.typed +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/settings/production.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/settings/test.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/templates/openedx_plugin_sample/base.html +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/urls.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/dependency_links.txt +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/entry_points.txt +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/top_level.txt +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/test_utils/__init__.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/tests/test_plugin_integration.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/tests/urls.py +0 -0
- {openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/tox.ini +0 -0
|
@@ -39,3 +39,11 @@ waffle.Sample:
|
|
|
39
39
|
".. no_pii:": "This model has no PII"
|
|
40
40
|
waffle.Switch:
|
|
41
41
|
".. no_pii:": "This model has no PII"
|
|
42
|
+
openedx_catalog.CatalogCourse:
|
|
43
|
+
".. no_pii:": "This model has no PII"
|
|
44
|
+
openedx_catalog.CourseRun:
|
|
45
|
+
".. no_pii:": "This model has no PII"
|
|
46
|
+
organizations.HistoricalOrganization:
|
|
47
|
+
".. no_pii:": "This model has no PII"
|
|
48
|
+
organizations.HistoricalOrganizationCourse:
|
|
49
|
+
".. no_pii:": "This model has no PII"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openedx-plugin-sample
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.2.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
|
|
@@ -21,6 +21,7 @@ Requires-Dist: Django
|
|
|
21
21
|
Requires-Dist: djangorestframework
|
|
22
22
|
Requires-Dist: django-filter
|
|
23
23
|
Requires-Dist: edx-opaque-keys
|
|
24
|
+
Requires-Dist: openedx-core
|
|
24
25
|
Requires-Dist: openedx-events
|
|
25
26
|
Requires-Dist: openedx-filters
|
|
26
27
|
Requires-Dist: openedx-atlas
|
|
@@ -212,40 +213,62 @@ def perform_create(self, serializer):
|
|
|
212
213
|
|
|
213
214
|
### Event Handler Example
|
|
214
215
|
|
|
216
|
+
This plugin reacts to `COURSE_ENROLLMENT_CHANGED` to unarchive a course on the
|
|
217
|
+
learner's dashboard when they upgrade to the verified track. The idea: a learner
|
|
218
|
+
who has previously archived a course shouldn't have to dig it back out of their
|
|
219
|
+
"Archived" section after upgrading -- their renewed investment is a strong
|
|
220
|
+
signal that the course belongs back in their active list.
|
|
221
|
+
|
|
215
222
|
```python
|
|
216
|
-
from openedx_events.
|
|
223
|
+
from openedx_events.learning.data import CourseEnrollmentData
|
|
224
|
+
from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED
|
|
217
225
|
from django.dispatch import receiver
|
|
218
226
|
|
|
219
|
-
@receiver(
|
|
220
|
-
def
|
|
221
|
-
|
|
222
|
-
|
|
227
|
+
@receiver(COURSE_ENROLLMENT_CHANGED)
|
|
228
|
+
def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
|
|
229
|
+
if not enrollment.is_active or enrollment.mode != "verified":
|
|
230
|
+
return
|
|
231
|
+
CourseArchiveStatus.objects.filter(
|
|
232
|
+
user_id=enrollment.user.id,
|
|
233
|
+
course_id=enrollment.course.course_key,
|
|
234
|
+
is_archived=True,
|
|
235
|
+
).update(is_archived=False, archive_date=None)
|
|
223
236
|
```
|
|
224
237
|
|
|
238
|
+
**Why an event (not a filter)?** The unarchive is a *one-time nudge*: if the
|
|
239
|
+
learner re-archives the course later, we respect that. Implementing this as a
|
|
240
|
+
continuous rule in the filter pipeline (e.g. "any verified course is never
|
|
241
|
+
archived") would override the learner's intent. Events fire at the moment a
|
|
242
|
+
state change happens, which is exactly when this kind of one-shot reaction
|
|
243
|
+
belongs.
|
|
244
|
+
|
|
225
245
|
### Available Events
|
|
226
246
|
|
|
227
247
|
**Event Catalog**: [Open edX Events Reference](https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html)
|
|
228
248
|
|
|
229
249
|
**Common Events:**
|
|
230
|
-
- `
|
|
250
|
+
- `COURSE_ENROLLMENT_CHANGED` - Enrollment becomes active/inactive or changes mode
|
|
251
|
+
- `COURSE_ENROLLMENT_CREATED` - Student newly enrolled in a course
|
|
231
252
|
- `STUDENT_REGISTRATION_COMPLETED` - New user registered
|
|
232
253
|
- `CERTIFICATE_CREATED` - Certificate generated for learner
|
|
233
|
-
- `
|
|
254
|
+
- `COURSE_CATALOG_INFO_CHANGED` - Course catalog metadata updated
|
|
234
255
|
|
|
235
256
|
### Event Data Structure
|
|
236
257
|
|
|
237
|
-
Each event includes specific data. For `
|
|
258
|
+
Each event includes a specific data object. For `COURSE_ENROLLMENT_CHANGED`:
|
|
238
259
|
|
|
239
260
|
```python
|
|
240
|
-
def
|
|
241
|
-
#
|
|
242
|
-
# -
|
|
243
|
-
# -
|
|
244
|
-
# -
|
|
245
|
-
# -
|
|
261
|
+
def unarchive_on_verified_upgrade(signal, sender, enrollment: CourseEnrollmentData, **kwargs):
|
|
262
|
+
# enrollment contains:
|
|
263
|
+
# - user: UserData (with .id, .is_active, .pii)
|
|
264
|
+
# - course: CourseData (with .course_key, .display_name, .start, .end)
|
|
265
|
+
# - mode: str (e.g. "audit", "verified", "honor")
|
|
266
|
+
# - is_active: bool
|
|
267
|
+
# - creation_date: datetime
|
|
268
|
+
# - created_by: UserData (optional)
|
|
246
269
|
```
|
|
247
270
|
|
|
248
|
-
**Key Point**: Check the [event
|
|
271
|
+
**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
272
|
|
|
250
273
|
### Signal Handler Registration
|
|
251
274
|
|
|
@@ -499,15 +522,19 @@ const response = await client.get(
|
|
|
499
522
|
);
|
|
500
523
|
```
|
|
501
524
|
|
|
502
|
-
### Events +
|
|
525
|
+
### Events + Models Integration
|
|
503
526
|
|
|
504
527
|
```python
|
|
505
|
-
@receiver(
|
|
506
|
-
def
|
|
507
|
-
#
|
|
528
|
+
@receiver(COURSE_ENROLLMENT_CHANGED)
|
|
529
|
+
def unarchive_on_verified_upgrade(signal, sender, enrollment, **kwargs):
|
|
530
|
+
# React to a verified upgrade by clearing the learner's archive flag
|
|
531
|
+
if not enrollment.is_active or enrollment.mode != "verified":
|
|
532
|
+
return
|
|
508
533
|
CourseArchiveStatus.objects.filter(
|
|
509
|
-
|
|
510
|
-
|
|
534
|
+
user_id=enrollment.user.id,
|
|
535
|
+
course_id=enrollment.course.course_key,
|
|
536
|
+
is_archived=True,
|
|
537
|
+
).update(is_archived=False, archive_date=None)
|
|
511
538
|
```
|
|
512
539
|
|
|
513
540
|
### 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.
|
|
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(
|
|
192
|
-
def
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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 `
|
|
229
|
+
Each event includes a specific data object. For `COURSE_ENROLLMENT_CHANGED`:
|
|
210
230
|
|
|
211
231
|
```python
|
|
212
|
-
def
|
|
213
|
-
#
|
|
214
|
-
# -
|
|
215
|
-
# -
|
|
216
|
-
# -
|
|
217
|
-
# -
|
|
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
|
|
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 +
|
|
496
|
+
### Events + Models Integration
|
|
475
497
|
|
|
476
498
|
```python
|
|
477
|
-
@receiver(
|
|
478
|
-
def
|
|
479
|
-
#
|
|
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
|
-
|
|
482
|
-
|
|
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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django admin configuration for openedx_plugin_sample.
|
|
3
|
+
|
|
4
|
+
This module demonstrates how to expose plugin models in the Django admin
|
|
5
|
+
site provided by Open edX (LMS and CMS each have their own admin under
|
|
6
|
+
``/admin/``). Defining a ``ModelAdmin`` for each model gives operators a
|
|
7
|
+
ready-made UI to inspect and manage plugin data without needing custom
|
|
8
|
+
tooling.
|
|
9
|
+
|
|
10
|
+
Django Documentation:
|
|
11
|
+
- ModelAdmin: https://docs.djangoproject.com/en/stable/ref/contrib/admin/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from django.contrib import admin
|
|
15
|
+
|
|
16
|
+
from openedx_plugin_sample.models import CourseArchiveStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@admin.register(CourseArchiveStatus)
|
|
20
|
+
class CourseArchiveStatusAdmin(admin.ModelAdmin):
|
|
21
|
+
"""
|
|
22
|
+
Admin configuration for the CourseArchiveStatus model.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
list_display = (
|
|
26
|
+
"course_key",
|
|
27
|
+
"user",
|
|
28
|
+
"is_archived",
|
|
29
|
+
"archive_date",
|
|
30
|
+
"updated_at",
|
|
31
|
+
)
|
|
32
|
+
list_filter = ("is_archived",)
|
|
33
|
+
# Search by the related CourseRun's course_key and the user's username/email.
|
|
34
|
+
search_fields = (
|
|
35
|
+
"course_run__course_key",
|
|
36
|
+
"user__username",
|
|
37
|
+
"user__email",
|
|
38
|
+
)
|
|
39
|
+
# FKs use raw id widgets (lookup popup) rather than a <select>, since the
|
|
40
|
+
# CourseRun and User tables can have many thousands of rows on a real
|
|
41
|
+
# Open edX deployment.
|
|
42
|
+
raw_id_fields = ("course_run", "user")
|
|
43
|
+
readonly_fields = ("created_at", "updated_at")
|
|
44
|
+
ordering = ("-updated_at",)
|
|
45
|
+
|
|
46
|
+
@admin.display(description="Course key", ordering="course_run__course_key")
|
|
47
|
+
def course_key(self, obj: CourseArchiveStatus) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Show the course's course_key string in list_display.
|
|
50
|
+
|
|
51
|
+
We never expose CourseRun's internal integer PK in the admin; the
|
|
52
|
+
course_key (e.g. "course-v1:edX+DemoX+Demo_Course") is the identifier
|
|
53
|
+
operators recognize.
|
|
54
|
+
"""
|
|
55
|
+
return str(obj.course_run.course_key)
|
{openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/apps.py
RENAMED
|
@@ -92,8 +92,8 @@ class SamplePluginConfig(AppConfig):
|
|
|
92
92
|
# "lms.djangoapp": {
|
|
93
93
|
# "relative_path": "signals",
|
|
94
94
|
# "receivers": [{
|
|
95
|
-
# "receiver_func_name": "
|
|
96
|
-
# "signal_path": "openedx_events.
|
|
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,36 @@
|
|
|
1
|
+
# Generated by Django 5.2.13 on 2026-05-14 01:13
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
('openedx_catalog', '0001_initial'),
|
|
14
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name='CourseArchiveStatus',
|
|
20
|
+
fields=[
|
|
21
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
22
|
+
('is_archived', models.BooleanField(db_index=True, default=False, help_text='Whether the course is archived.')),
|
|
23
|
+
('archive_date', models.DateTimeField(blank=True, help_text='The date and time when the course was archived.', null=True)),
|
|
24
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
25
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
26
|
+
('course_run', models.ForeignKey(help_text='The course run that this archive status is for.', on_delete=django.db.models.deletion.CASCADE, related_name='archive_statuses', to='openedx_catalog.courserun')),
|
|
27
|
+
('user', models.ForeignKey(help_text='The user who this archive status is for.', on_delete=django.db.models.deletion.CASCADE, related_name='course_archive_statuses', to=settings.AUTH_USER_MODEL)),
|
|
28
|
+
],
|
|
29
|
+
options={
|
|
30
|
+
'verbose_name': 'Course Archive Status',
|
|
31
|
+
'verbose_name_plural': 'Course Archive Statuses',
|
|
32
|
+
'ordering': ['-updated_at'],
|
|
33
|
+
'constraints': [models.UniqueConstraint(fields=('course_run', 'user'), name='unique_user_course_archive_status')],
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
]
|
{openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/models.py
RENAMED
|
@@ -4,7 +4,7 @@ Database models for openedx_plugin_sample.
|
|
|
4
4
|
|
|
5
5
|
from django.contrib.auth import get_user_model
|
|
6
6
|
from django.db import models
|
|
7
|
-
from
|
|
7
|
+
from openedx_catalog.models import CourseRun
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class CourseArchiveStatus(models.Model):
|
|
@@ -16,8 +16,11 @@ class CourseArchiveStatus(models.Model):
|
|
|
16
16
|
.. no_pii: This model does not store PII directly, only references to users via foreign keys.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
course_run = models.ForeignKey(
|
|
20
|
+
CourseRun,
|
|
21
|
+
on_delete=models.CASCADE,
|
|
22
|
+
related_name="archive_statuses",
|
|
23
|
+
help_text="The course run that this archive status is for.",
|
|
21
24
|
)
|
|
22
25
|
|
|
23
26
|
user = models.ForeignKey(
|
|
@@ -47,7 +50,9 @@ class CourseArchiveStatus(models.Model):
|
|
|
47
50
|
Return a string representation of the course archive status.
|
|
48
51
|
"""
|
|
49
52
|
# pylint: disable=no-member
|
|
50
|
-
|
|
53
|
+
# Identify the course by its course_key string, never by the internal PK.
|
|
54
|
+
archived = "Archived" if self.is_archived else "Not Archived"
|
|
55
|
+
return f"{self.course_run.course_key} - {self.user.username} - {archived}"
|
|
51
56
|
|
|
52
57
|
class Meta:
|
|
53
58
|
"""
|
|
@@ -57,9 +62,9 @@ class CourseArchiveStatus(models.Model):
|
|
|
57
62
|
verbose_name = "Course Archive Status"
|
|
58
63
|
verbose_name_plural = "Course Archive Statuses"
|
|
59
64
|
ordering = ["-updated_at"]
|
|
60
|
-
# Ensure combination of
|
|
65
|
+
# Ensure combination of course_run and user is unique
|
|
61
66
|
constraints = [
|
|
62
67
|
models.UniqueConstraint(
|
|
63
|
-
fields=["
|
|
68
|
+
fields=["course_run", "user"], name="unique_user_course_archive_status"
|
|
64
69
|
)
|
|
65
70
|
]
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
"""
|
|
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,
|
|
82
|
+
course_run__course_key=serialized_courserun["courseId"],
|
|
83
|
+
).is_archived
|
|
84
|
+
except CourseArchiveStatus.DoesNotExist:
|
|
85
|
+
is_archived_by_learner = False
|
|
86
|
+
return {
|
|
87
|
+
"serialized_courserun": {
|
|
88
|
+
**serialized_courserun,
|
|
89
|
+
"isArchivedByLearner": is_archived_by_learner,
|
|
90
|
+
},
|
|
91
|
+
}
|
{openedx_plugin_sample-3.0.1 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/serializers.py
RENAMED
|
@@ -3,6 +3,7 @@ Serializers for the openedx_plugin_sample app.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from django.contrib.auth import get_user_model
|
|
6
|
+
from openedx_catalog.models import CourseRun
|
|
6
7
|
from rest_framework import serializers
|
|
7
8
|
|
|
8
9
|
from openedx_plugin_sample.models import CourseArchiveStatus
|
|
@@ -21,6 +22,16 @@ class CourseArchiveStatusSerializer(serializers.ModelSerializer):
|
|
|
21
22
|
required=False,
|
|
22
23
|
)
|
|
23
24
|
|
|
25
|
+
# The model stores a FK to CourseRun, but APIs should identify courses by
|
|
26
|
+
# their full course key string (e.g. "course-v1:edX+DemoX+Demo_Course"),
|
|
27
|
+
# never by CourseRun's internal integer PK. The slug field looks up the
|
|
28
|
+
# related CourseRun by its `course_key` for both reads and writes.
|
|
29
|
+
course_id = serializers.SlugRelatedField(
|
|
30
|
+
source="course_run",
|
|
31
|
+
slug_field="course_key",
|
|
32
|
+
queryset=CourseRun.objects.all(),
|
|
33
|
+
)
|
|
34
|
+
|
|
24
35
|
class Meta:
|
|
25
36
|
"""
|
|
26
37
|
Meta class for CourseArchiveStatusSerializer.
|
|
@@ -37,3 +48,14 @@ class CourseArchiveStatusSerializer(serializers.ModelSerializer):
|
|
|
37
48
|
"updated_at",
|
|
38
49
|
]
|
|
39
50
|
read_only_fields = ["id", "created_at", "updated_at", "archive_date"]
|
|
51
|
+
|
|
52
|
+
def to_representation(self, instance):
|
|
53
|
+
"""
|
|
54
|
+
Serialize the instance, casting course_id to a string.
|
|
55
|
+
|
|
56
|
+
CourseRun.course_key returns a CourseLocator (not a string), which the
|
|
57
|
+
default JSON encoder can't serialize, so we coerce to str on output.
|
|
58
|
+
"""
|
|
59
|
+
data = super().to_representation(instance)
|
|
60
|
+
data["course_id"] = str(data["course_id"])
|
|
61
|
+
return data
|
|
@@ -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:
|
|
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.
|
|
100
|
-
our_pipeline_step = "openedx_plugin_sample.pipeline.
|
|
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_run__course_key=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
|
+
)
|