Skip to content

Commit 3c39178

Browse files
committed
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.
1 parent 913f1b1 commit 3c39178

4 files changed

Lines changed: 85 additions & 262 deletions

File tree

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

Lines changed: 22 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -39,118 +39,37 @@
3939

4040
import logging
4141
import re
42+
import crum
4243

4344
from openedx_filters.filters import PipelineStep
4445

46+
from .models import CourseArchiveStatus
47+
4548
logger = logging.getLogger(__name__)
4649

4750

48-
class ChangeCourseAboutPageUrl(PipelineStep):
51+
class AddArchiveStatusToLearnerHomeCourseRun(PipelineStep):
4952
"""
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
53+
Customize each courseRun within a Learner Dashboard's /init API response to include the CourseArchiveStatus.
8154
""" # noqa: E501
8255

83-
def run_filter(self, url, org, **kwargs): # pylint: disable=arguments-differ
56+
def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=arguments-differ
8457
"""
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.
89-
90-
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
94-
95-
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
58+
Return a modified `serialized_courserun`, the data for one courseRun sent to the Learner Dashboard.
10259
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
60+
Inserts the `isArchivedByLearner` field (disambiguated from `isArchived`, which refers to whether the course run has
61+
been archived by the authors).
11562
""" # 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}
63+
user = crum.get_current_request().user
64+
try:
65+
is_archived_by_learner = CourseArchiveStatus.objects.get(
66+
user=user, course_id=serialized_courserun["courseId"]
67+
).is_archived
68+
except CourseArchiveStatus.DoesNotExist:
69+
is_archived_by_learner = False
70+
return {
71+
"serialized_courserun": {
72+
**serialized_courserun,
73+
"isArchivedByLearner": is_archived_by_learner,
74+
},
75+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ def _configure_openedx_filters(settings):
9696
filters_config = getattr(settings, 'OPEN_EDX_FILTERS_CONFIG', {})
9797

9898
# 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"
99+
filter_name = "org.openedx.learning.home.courserun.api.rendered.started.v1"
100+
our_pipeline_step = "openedx_plugin_sample.pipeline.AddArchiveStatusToLearnerHomeCourseRun"
101101

102102
# Check if this filter already has configuration
103103
if filter_name in filters_config:

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)