Skip to content

Commit e9b5159

Browse files
kdmccormickclaude
andcommitted
refactor: Inject isArchivedByLearner into initial API response
This both: * demonstrates filter pipeline steps in a practical way which relates to the sample archive functionality. * removes the need to call GET ..../course-archive-status/ in order to render the page. The GET API still exists, as it's called to update the state after a learner clicks Archive/Unarchive (could probably be factored away, but not important) This removes the old sample filter pipeline step, which changed the course about URL into an example.com URL. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 233d586 commit e9b5159

5 files changed

Lines changed: 201 additions & 261 deletions

File tree

backend-plugin-sample/src/openedx_plugin_sample/pipeline.py

Lines changed: 35 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -38,119 +38,53 @@
3838
""" # pylint: disable=line-too-long
3939

4040
import logging
41-
import re
4241

42+
import crum
4343
from openedx_filters.filters import PipelineStep
4444

45+
from .models import CourseArchiveStatus
46+
4547
logger = logging.getLogger(__name__)
4648

4749

48-
class ChangeCourseAboutPageUrl(PipelineStep):
50+
class AddArchiveStatusToLearnerHomeCourseRun(PipelineStep):
4951
"""
50-
Filter to customize course about page URLs.
51-
52-
This filter demonstrates how to intercept and modify course about page URLs,
53-
redirecting them to external sites or custom implementations.
54-
55-
Filter Hook Point:
56-
This filter hooks into the course about page URL rendering process.
57-
Register it for the filter: org.openedx.learning.course.about.render.started.v1
58-
59-
Registration Example (in settings/common.py)::
60-
61-
def plugin_settings(settings):
62-
settings.OPEN_EDX_FILTERS_CONFIG = {
63-
"org.openedx.learning.course.about.render.started.v1": {
64-
"pipeline": [
65-
"openedx_plugin_sample.pipeline.ChangeCourseAboutPageUrl"
66-
],
67-
"fail_silently": False,
68-
}
69-
}
70-
71-
Filter Documentation:
72-
- Available Filters: https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters.html
73-
- PipelineStep: https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters-tooling.html#openedx_filters.filters.PipelineStep
74-
75-
Real-World Use Cases:
76-
- Redirect to marketing site course pages
77-
- Implement custom course discovery interfaces
78-
- Add tracking parameters to URLs
79-
- Route different course types to different platforms
80-
- Implement A/B testing for course pages
52+
Customize each courseRun within a Learner Dashboard's /init API response to include the CourseArchiveStatus.
8153
""" # noqa: E501
8254

83-
def run_filter(self, url, org, **kwargs): # pylint: disable=arguments-differ
55+
def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=arguments-differ
8456
"""
85-
Modify the course about page URL.
86-
87-
This method intercepts course about page URL generation and can modify
88-
the destination URL based on business logic.
57+
Insert `isArchivedByLearner` into one serialized courseRun for the Learner Home /init response.
8958
9059
Args:
91-
url (str): The original course about page URL
92-
org (str): The organization/institution identifier
93-
**kwargs: Additional context data from the platform
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.
9463
9564
Returns:
96-
dict: Dictionary with same parameter names as input
97-
- url (str): Modified or original URL
98-
- org (str): Organization identifier (usually unchanged)
99-
100-
Raises:
101-
FilterException: If processing should be halted
102-
103-
Filter Requirements:
104-
- Must return dictionary with keys matching input parameters
105-
- Return None to skip this filter (let other filters run)
106-
- Raise FilterException to halt pipeline execution
107-
- Handle all input scenarios gracefully
108-
109-
URL Pattern Matching:
110-
This implementation looks for Open edX course keys in the format:
111-
course-v1:ORG+COURSE+RUN (e.g., course-v1:edX+DemoX+Demo_Course)
112-
113-
Documentation:
114-
- run_filter method: https://docs.openedx.org/projects/openedx-filters/en/latest/reference/filters-tooling.html#openedx_filters.filters.PipelineStep.run_filter
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.
11575
""" # noqa: E501
116-
# Extract course ID using Open edX course key pattern
117-
# Course keys follow the format: course-v1:ORG+COURSE+RUN
118-
pattern = r'(?P<course_id>course-v1:[^/]+)'
119-
120-
match = re.search(pattern, url)
121-
if match:
122-
course_id = match.group('course_id')
123-
124-
# Example: Redirect to external marketing site
125-
new_url = f"https://example.com/new_about_page/{course_id}"
126-
127-
logger.debug(
128-
f"Redirecting course about page for {course_id} from {url} to {new_url}"
129-
)
130-
131-
# Return modified data
132-
return {"url": new_url, "org": org}
133-
134-
# No course ID found - return original data unchanged
135-
logger.debug(f"No course ID found in URL {url}, leaving unchanged")
136-
return {"url": url, "org": org}
137-
138-
# Alternative patterns for different business logic:
139-
140-
# Organization-based routing:
141-
# if org == "special_org":
142-
# new_url = f"https://special-site.com/courses/{course_id}"
143-
# return {"url": new_url, "org": org}
144-
145-
# Course type-based routing:
146-
# if "MicroMasters" in course_id:
147-
# new_url = f"https://micromasters.example.com/{course_id}"
148-
# return {"url": new_url, "org": org}
149-
150-
# A/B testing implementation:
151-
# import random
152-
# if random.choice([True, False]):
153-
# new_url = f"https://variant-a.example.com/{course_id}"
154-
# else:
155-
# new_url = f"https://variant-b.example.com/{course_id}"
156-
# return {"url": new_url, "org": org}
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+
}

backend-plugin-sample/src/openedx_plugin_sample/settings/common.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
configuration that integrates seamlessly with the platform.
1212
1313
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
1516
- Django Settings: https://docs.djangoproject.com/en/stable/topics/settings/
1617
1718
Settings Organization:
@@ -96,8 +97,8 @@ def _configure_openedx_filters(settings):
9697
filters_config = getattr(settings, 'OPEN_EDX_FILTERS_CONFIG', {})
9798

9899
# 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"
101102

102103
# Check if this filter already has configuration
103104
if filter_name in filters_config:
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

frontend-plugin-sample/README.md

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -90,33 +90,35 @@ const courses = courseListData.visibleList;
9090

9191
**Slot Props**: Each slot provides specific data. For CourseListSlot, see the [slot documentation](https://github.com/openedx/frontend-app-learner-dashboard/tree/master/src/plugin-slots/CourseListSlot#plugin-props).
9292

93-
#### 2. Backend API Integration
93+
#### 2. Backend Data via the Filter Pipeline
9494

95-
```jsx
96-
useEffect(() => {
97-
const fetchArchivedCourses = async () => {
98-
const client = getAuthenticatedHttpClient();
99-
const lmsBaseUrl = getConfig().LMS_BASE_URL;
100-
101-
const response = await client.get(
102-
`${lmsBaseUrl}/sample-plugin/api/v1/course-archive-status/`,
103-
{ params: { is_archived: true } }
104-
);
105-
106-
const archivedCourseIds = new Set(
107-
response.data.results.map((item) => item.course_id)
108-
);
109-
setArchivedCourses(archivedCourseIds);
110-
};
95+
Rather than firing an extra GET to `course-archive-status/` on every dashboard
96+
load, the initial archive state is read directly off the slot props. The backend
97+
plugin uses an Open edX filter (see [`pipeline.py`](../backend-plugin-sample/src/openedx_plugin_sample/pipeline.py))
98+
to inject `isArchivedByLearner` into each courseRun in the Learner Home `/init`
99+
API response, so it arrives alongside the rest of the course data:
111100

112-
fetchArchivedCourses();
113-
}, []);
101+
```jsx
102+
const [archivedCourses, setArchivedCourses] = useState(() => {
103+
const initial = new Set();
104+
(courseListData?.visibleList || []).forEach((courseData) => {
105+
if (courseData.courseRun?.isArchivedByLearner) {
106+
initial.add(courseData.courseRun.courseId);
107+
}
108+
});
109+
return initial;
110+
});
114111
```
115112
113+
**Why this pattern**: One fewer round-trip per dashboard load, and the archive
114+
state is consistent with the rest of the course data from the same response.
115+
The REST API is still used for writes (archive/unarchive) — see the toggle
116+
handler below.
117+
116118
**Key Patterns:**
117-
- **Authentication**: `getAuthenticatedHttpClient()` handles Open edX auth
119+
- **Filter-injected data**: Read `courseRun.isArchivedByLearner` straight from slot props
120+
- **Authentication** (for writes): `getAuthenticatedHttpClient()` handles Open edX auth
118121
- **Configuration**: `getConfig().LMS_BASE_URL` gets platform URLs
119-
- **Error Handling**: Try/catch blocks for API failures
120122
121123
#### 3. Open edX UI Components
122124

0 commit comments

Comments
 (0)