openedx-plugin-sample 3.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openedx_plugin_sample/__init__.py +7 -0
- openedx_plugin_sample/admin.py +55 -0
- openedx_plugin_sample/apps.py +134 -0
- openedx_plugin_sample/conf/locale/config.yaml +85 -0
- openedx_plugin_sample/migrations/0001_initial.py +36 -0
- openedx_plugin_sample/migrations/__init__.py +0 -0
- openedx_plugin_sample/models.py +70 -0
- openedx_plugin_sample/pipeline.py +91 -0
- openedx_plugin_sample/py.typed +0 -0
- openedx_plugin_sample/serializers.py +61 -0
- openedx_plugin_sample/settings/common.py +136 -0
- openedx_plugin_sample/settings/production.py +16 -0
- openedx_plugin_sample/settings/test.py +17 -0
- openedx_plugin_sample/signals.py +66 -0
- openedx_plugin_sample/templates/openedx_plugin_sample/base.html +26 -0
- openedx_plugin_sample/urls.py +21 -0
- openedx_plugin_sample/views.py +268 -0
- openedx_plugin_sample-3.8.0.dist-info/METADATA +66 -0
- openedx_plugin_sample-3.8.0.dist-info/RECORD +23 -0
- openedx_plugin_sample-3.8.0.dist-info/WHEEL +5 -0
- openedx_plugin_sample-3.8.0.dist-info/entry_points.txt +5 -0
- openedx_plugin_sample-3.8.0.dist-info/licenses/LICENSE.txt +180 -0
- openedx_plugin_sample-3.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Production settings for the openedx_plugin_sample application.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from openedx_plugin_sample.settings.common import plugin_settings as common_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def plugin_settings(settings):
|
|
9
|
+
"""
|
|
10
|
+
Set up production-specific settings.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
settings (dict): Django settings object
|
|
14
|
+
"""
|
|
15
|
+
# Apply common settings
|
|
16
|
+
common_settings(settings)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test settings for the openedx_plugin_sample application.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from openedx_plugin_sample.settings.common import plugin_settings as common_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def plugin_settings(settings):
|
|
9
|
+
"""
|
|
10
|
+
Set up test-specific settings.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
settings (dict): Django settings object
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Apply common settings
|
|
17
|
+
common_settings(settings)
|
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
{% load i18n %}
|
|
4
|
+
{% trans "Dummy text to generate a translation (.po) source file. It is safe to delete this line. It is also safe to delete (load i18n) above if there are no other (trans) tags in the file" %}
|
|
5
|
+
|
|
6
|
+
{% comment %}
|
|
7
|
+
As the developer of this package, don't place anything here if you can help it
|
|
8
|
+
since this allows developers to have interoperability between your template
|
|
9
|
+
structure and their own.
|
|
10
|
+
|
|
11
|
+
Example: Developer melding the 2SoD pattern to fit inside with another pattern::
|
|
12
|
+
|
|
13
|
+
{% extends "base.html" %}
|
|
14
|
+
{% load static %}
|
|
15
|
+
|
|
16
|
+
<!-- Their site uses old school block layout -->
|
|
17
|
+
{% block extra_js %}
|
|
18
|
+
|
|
19
|
+
<!-- Your package using 2SoD block layout -->
|
|
20
|
+
{% block javascript %}
|
|
21
|
+
<script src="{% static 'js/ninja.js' %}" type="text/javascript"></script>
|
|
22
|
+
{% endblock javascript %}
|
|
23
|
+
|
|
24
|
+
{% endblock extra_js %}
|
|
25
|
+
{% endcomment %}
|
|
26
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URLs for openedx_plugin_sample.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.urls import include, path
|
|
6
|
+
from rest_framework.routers import DefaultRouter
|
|
7
|
+
|
|
8
|
+
from openedx_plugin_sample.views import CourseArchiveStatusViewSet
|
|
9
|
+
|
|
10
|
+
# Create a router and register our viewsets with it
|
|
11
|
+
router = DefaultRouter()
|
|
12
|
+
router.register(
|
|
13
|
+
r"course-archive-status",
|
|
14
|
+
CourseArchiveStatusViewSet,
|
|
15
|
+
basename="course-archive-status",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# The API URLs are now determined automatically by the router
|
|
19
|
+
urlpatterns = [
|
|
20
|
+
path("api/v1/", include(router.urls)),
|
|
21
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Views for the openedx_plugin_sample app.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from django.utils import timezone
|
|
8
|
+
from django_filters import rest_framework as django_filters
|
|
9
|
+
from django_filters.rest_framework import DjangoFilterBackend
|
|
10
|
+
from opaque_keys import InvalidKeyError
|
|
11
|
+
from opaque_keys.edx.keys import CourseKey
|
|
12
|
+
from rest_framework import filters, permissions, viewsets
|
|
13
|
+
from rest_framework.exceptions import PermissionDenied, ValidationError
|
|
14
|
+
from rest_framework.pagination import PageNumberPagination
|
|
15
|
+
from rest_framework.throttling import UserRateThrottle
|
|
16
|
+
|
|
17
|
+
from openedx_plugin_sample.models import CourseArchiveStatus
|
|
18
|
+
from openedx_plugin_sample.serializers import CourseArchiveStatusSerializer
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IsOwnerOrStaffSuperuser(permissions.BasePermission):
|
|
24
|
+
"""
|
|
25
|
+
Custom permission to only allow owners of an object or staff/superusers to view or edit it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def has_permission(self, request, view):
|
|
29
|
+
"""
|
|
30
|
+
Return True if permission is granted to the view.
|
|
31
|
+
"""
|
|
32
|
+
# Allow authenticated users to list and create
|
|
33
|
+
return request.user and request.user.is_authenticated
|
|
34
|
+
|
|
35
|
+
def has_object_permission(self, request, view, obj):
|
|
36
|
+
"""
|
|
37
|
+
Return True if permission is granted to the object.
|
|
38
|
+
"""
|
|
39
|
+
# Allow if the object belongs to the requesting user
|
|
40
|
+
if obj.user == request.user:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
# Allow staff users and superusers
|
|
44
|
+
if request.user.is_staff or request.user.is_superuser:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CourseArchiveStatusPagination(PageNumberPagination):
|
|
51
|
+
"""
|
|
52
|
+
Pagination class for CourseArchiveStatus.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
page_size = 20
|
|
56
|
+
page_size_query_param = "page_size"
|
|
57
|
+
max_page_size = 100
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CourseArchiveStatusThrottle(UserRateThrottle):
|
|
61
|
+
"""
|
|
62
|
+
Throttle for the CourseArchiveStatus API.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
rate = "60/minute"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CourseArchiveStatusFilterSet(django_filters.FilterSet):
|
|
69
|
+
"""
|
|
70
|
+
FilterSet for CourseArchiveStatus.
|
|
71
|
+
|
|
72
|
+
The model stores a FK to CourseRun, but the public API filters and orders
|
|
73
|
+
by the course_key string (never by the internal CourseRun PK).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# Map ?course_id=course-v1:... onto the FK's course_key column.
|
|
77
|
+
course_id = django_filters.CharFilter(field_name="course_run__course_key")
|
|
78
|
+
|
|
79
|
+
# Expose ?ordering=course_id (and other fields) without leaking the
|
|
80
|
+
# double-underscore FK lookup path.
|
|
81
|
+
ordering = django_filters.OrderingFilter(
|
|
82
|
+
fields=(
|
|
83
|
+
("course_run__course_key", "course_id"),
|
|
84
|
+
("user", "user"),
|
|
85
|
+
("is_archived", "is_archived"),
|
|
86
|
+
("archive_date", "archive_date"),
|
|
87
|
+
("created_at", "created_at"),
|
|
88
|
+
("updated_at", "updated_at"),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
class Meta:
|
|
93
|
+
"""
|
|
94
|
+
FilterSet Meta options for CourseArchiveStatus.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
model = CourseArchiveStatus
|
|
98
|
+
fields = ["course_id", "user", "is_archived"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
|
|
102
|
+
"""
|
|
103
|
+
API viewset for CourseArchiveStatus.
|
|
104
|
+
|
|
105
|
+
Allows users to view their own course archive statuses and staff/superusers to view all.
|
|
106
|
+
Pagination is applied with a default page size of 20 (max 100).
|
|
107
|
+
Filtering is available on course_id, user, and is_archived fields.
|
|
108
|
+
Ordering is available on all fields.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
serializer_class = CourseArchiveStatusSerializer
|
|
112
|
+
permission_classes = [IsOwnerOrStaffSuperuser]
|
|
113
|
+
pagination_class = CourseArchiveStatusPagination
|
|
114
|
+
throttle_classes = [
|
|
115
|
+
CourseArchiveStatusThrottle,
|
|
116
|
+
]
|
|
117
|
+
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
|
118
|
+
filterset_class = CourseArchiveStatusFilterSet
|
|
119
|
+
ordering = ["-updated_at"]
|
|
120
|
+
|
|
121
|
+
def get_queryset(self):
|
|
122
|
+
"""
|
|
123
|
+
Return the queryset for this viewset.
|
|
124
|
+
|
|
125
|
+
Regular users can only see their own records.
|
|
126
|
+
Staff and superusers can see all records but with optimized queries.
|
|
127
|
+
"""
|
|
128
|
+
user = self.request.user
|
|
129
|
+
|
|
130
|
+
# Validate query parameters to prevent injection
|
|
131
|
+
self._validate_query_params()
|
|
132
|
+
|
|
133
|
+
# Always use select_related to avoid N+1 queries when accessing
|
|
134
|
+
# related user and course_run (for course_key) fields.
|
|
135
|
+
base_queryset = CourseArchiveStatus.objects.select_related("user", "course_run")
|
|
136
|
+
|
|
137
|
+
if user.is_staff or user.is_superuser:
|
|
138
|
+
return base_queryset
|
|
139
|
+
|
|
140
|
+
# Regular users only see their own records
|
|
141
|
+
return base_queryset.filter(user=user)
|
|
142
|
+
|
|
143
|
+
def _validate_query_params(self):
|
|
144
|
+
"""
|
|
145
|
+
Validate query parameters to prevent injection.
|
|
146
|
+
"""
|
|
147
|
+
# Example validation for course_id format
|
|
148
|
+
course_id = self.request.query_params.get("course_id")
|
|
149
|
+
if course_id and not self._is_valid_course_id(course_id):
|
|
150
|
+
logger.warning(
|
|
151
|
+
"Invalid course_id in request: %s, user: %s",
|
|
152
|
+
course_id,
|
|
153
|
+
self.request.user.username,
|
|
154
|
+
)
|
|
155
|
+
raise ValidationError({"course_id": "Invalid course ID format."})
|
|
156
|
+
|
|
157
|
+
def _is_valid_course_id(self, course_id):
|
|
158
|
+
"""
|
|
159
|
+
Check if the course_id is in a valid format.
|
|
160
|
+
|
|
161
|
+
This is a basic implementation - in production, you might use a more
|
|
162
|
+
sophisticated validator from the edx-platform.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
CourseKey.from_string(course_id)
|
|
166
|
+
return True
|
|
167
|
+
except InvalidKeyError:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def perform_create(self, serializer):
|
|
171
|
+
"""
|
|
172
|
+
Perform creation of a new CourseArchiveStatus.
|
|
173
|
+
|
|
174
|
+
Validates permission for user override and sets archive_date if needed.
|
|
175
|
+
"""
|
|
176
|
+
# Check if user was explicitly provided and differs from current user
|
|
177
|
+
if "user" in self.request.data:
|
|
178
|
+
requested_user_id = self.request.data["user"]
|
|
179
|
+
if requested_user_id != self.request.user.id and not (
|
|
180
|
+
self.request.user.is_staff or self.request.user.is_superuser
|
|
181
|
+
):
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Permission denied: User %s tried to create a record for user %s",
|
|
184
|
+
self.request.user.username,
|
|
185
|
+
requested_user_id,
|
|
186
|
+
)
|
|
187
|
+
raise PermissionDenied(
|
|
188
|
+
"You do not have permission to create records for other users."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Set archive_date if is_archived is True
|
|
192
|
+
data = {}
|
|
193
|
+
if serializer.validated_data.get("is_archived", False):
|
|
194
|
+
data["archive_date"] = timezone.now()
|
|
195
|
+
|
|
196
|
+
# Create the record
|
|
197
|
+
instance = serializer.save(**data)
|
|
198
|
+
|
|
199
|
+
# Log at debug level for normal operation
|
|
200
|
+
logger.debug(
|
|
201
|
+
"CourseArchiveStatus created: course_id=%s, user=%s, is_archived=%s",
|
|
202
|
+
instance.course_run.course_key,
|
|
203
|
+
instance.user.username,
|
|
204
|
+
instance.is_archived,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return instance
|
|
208
|
+
|
|
209
|
+
def perform_update(self, serializer):
|
|
210
|
+
"""
|
|
211
|
+
Perform update of an existing CourseArchiveStatus.
|
|
212
|
+
|
|
213
|
+
Validates permission for user override and updates archive_date if needed.
|
|
214
|
+
"""
|
|
215
|
+
instance = serializer.instance
|
|
216
|
+
|
|
217
|
+
# Check if user was explicitly provided and differs from current user
|
|
218
|
+
if "user" in self.request.data:
|
|
219
|
+
requested_user_id = self.request.data["user"]
|
|
220
|
+
if requested_user_id != self.request.user.id and not (
|
|
221
|
+
self.request.user.is_staff or self.request.user.is_superuser
|
|
222
|
+
):
|
|
223
|
+
logger.warning(
|
|
224
|
+
"Permission denied: User %s tried to update a record for user %s",
|
|
225
|
+
self.request.user.username,
|
|
226
|
+
requested_user_id,
|
|
227
|
+
)
|
|
228
|
+
raise PermissionDenied(
|
|
229
|
+
"You do not have permission to update records for other users."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Handle archive_date if is_archived changes
|
|
233
|
+
data = {}
|
|
234
|
+
if "is_archived" in serializer.validated_data:
|
|
235
|
+
# If changing from not archived to archived
|
|
236
|
+
if serializer.validated_data["is_archived"] and not instance.is_archived:
|
|
237
|
+
data["archive_date"] = timezone.now()
|
|
238
|
+
# If changing from archived to not archived
|
|
239
|
+
elif not serializer.validated_data["is_archived"] and instance.is_archived:
|
|
240
|
+
data["archive_date"] = None
|
|
241
|
+
|
|
242
|
+
# Update the record
|
|
243
|
+
updated_instance = serializer.save(**data)
|
|
244
|
+
|
|
245
|
+
# Log at debug level
|
|
246
|
+
logger.debug(
|
|
247
|
+
"CourseArchiveStatus updated: course_id=%s, user=%s, is_archived=%s",
|
|
248
|
+
updated_instance.course_run.course_key,
|
|
249
|
+
updated_instance.user.username,
|
|
250
|
+
updated_instance.is_archived,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return updated_instance
|
|
254
|
+
|
|
255
|
+
def perform_destroy(self, instance):
|
|
256
|
+
"""
|
|
257
|
+
Perform deletion of an existing CourseArchiveStatus.
|
|
258
|
+
"""
|
|
259
|
+
# Log at debug level before deletion
|
|
260
|
+
logger.debug(
|
|
261
|
+
"CourseArchiveStatus deleted: course_id=%s, user=%s, by=%s",
|
|
262
|
+
instance.course_run.course_key,
|
|
263
|
+
instance.user.username,
|
|
264
|
+
self.request.user.username,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Delete the instance
|
|
268
|
+
return super().perform_destroy(instance)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openedx-plugin-sample
|
|
3
|
+
Version: 3.8.0
|
|
4
|
+
Summary: A sample backend plugin for the Open edX Platform
|
|
5
|
+
Author-email: Open edX Project <oscm@openedx.org>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/openedx/sample-plugin
|
|
8
|
+
Project-URL: Repository, https://github.com/openedx/sample-plugin
|
|
9
|
+
Keywords: Python,edx
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 5.2
|
|
13
|
+
Classifier: Framework :: Django :: 6.0
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Natural Language :: English
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE.txt
|
|
21
|
+
Requires-Dist: Django
|
|
22
|
+
Requires-Dist: djangorestframework
|
|
23
|
+
Requires-Dist: django-filter
|
|
24
|
+
Requires-Dist: edx-opaque-keys
|
|
25
|
+
Requires-Dist: openedx-core
|
|
26
|
+
Requires-Dist: openedx-events
|
|
27
|
+
Requires-Dist: openedx-filters
|
|
28
|
+
Requires-Dist: openedx-atlas
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# backend-plugin-sample
|
|
32
|
+
|
|
33
|
+
A Django app plugin for edx-platform that adds a small course-archiving feature: learners can mark courses as archived (hidden from their active list) and unarchive them later. It demonstrates three backend extension points working together:
|
|
34
|
+
|
|
35
|
+
- A model + REST API (`CourseArchiveStatus`), consumed by [`frontend-plugin-sample`](../frontend-plugin-sample/)
|
|
36
|
+
- An [Open edX Events](https://docs.openedx.org/projects/openedx-events/en/latest/) handler that auto-unarchives on verified upgrade
|
|
37
|
+
- An [Open edX Filters](https://docs.openedx.org/projects/openedx-filters/en/latest/) pipeline step that rewrites the course-about URL
|
|
38
|
+
|
|
39
|
+
## How to use it
|
|
40
|
+
|
|
41
|
+
See the root [README](../README.md) for setup instructions. With Tutor, [`tutor-contrib-sample`](../tutor-contrib-sample/) installs this plugin automatically (or bind-mounts your local checkout if you `tutor mounts add` it). Without Tutor, `pip install -e .` into your edx-platform environment and run migrations.
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
**Plugin registration.** [`apps.py`](./src/openedx_plugin_sample/apps.py) declares the Django app to edx-platform via the `plugin_app` config (URL routing, settings, signal registration). The entry points in [`pyproject.toml`](./pyproject.toml) make the platform discover the app automatically — no `INSTALLED_APPS` edit needed. See [How to create a plugin app](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html).
|
|
46
|
+
|
|
47
|
+
**Model.** [`models.py`](./src/openedx_plugin_sample/models.py) defines `CourseArchiveStatus(user, course_id, is_archived, archive_date)`, indexed for the lookups the API performs. Registered in Django admin via [`admin.py`](./src/openedx_plugin_sample/admin.py).
|
|
48
|
+
|
|
49
|
+
**REST API.** [`views.py`](./src/openedx_plugin_sample/views.py) exposes the model as a DRF `ModelViewSet` at `/sample-plugin/api/v1/course-archive-status/`, with per-user permissions, throttling, and pagination. Serializer in [`serializers.py`](./src/openedx_plugin_sample/serializers.py); URLs in [`urls.py`](./src/openedx_plugin_sample/urls.py). Business logic (e.g. setting `archive_date` when `is_archived` becomes true) lives in `perform_create`/`perform_update` rather than in the serializer.
|
|
50
|
+
|
|
51
|
+
**Event handler.** [`signals.py`](./src/openedx_plugin_sample/signals.py) listens for `COURSE_ENROLLMENT_CHANGED` and unarchives a learner's course when they upgrade to the verified track. An event (not a filter) is the right shape here because we want a one-time nudge at the moment of upgrade — if the learner re-archives the course later, we respect that. A filter would re-impose the rule on every render.
|
|
52
|
+
|
|
53
|
+
**Filter.** [`pipeline.py`](./src/openedx_plugin_sample/pipeline.py) implements `ChangeCourseAboutPageUrl`, a `PipelineStep` for `org.openedx.learning.course.about.render.started.v1` that rewrites course-about URLs to an external host. Registered via `OPEN_EDX_FILTERS_CONFIG` in [`settings/common.py`](./src/openedx_plugin_sample/settings/common.py).
|
|
54
|
+
|
|
55
|
+
**Settings.** Per-environment settings live in [`settings/`](./src/openedx_plugin_sample/settings/) (`common.py`, `production.py`, `test.py`). The plugin app loads these via its `plugin_app` config in `apps.py`.
|
|
56
|
+
|
|
57
|
+
## Testing and quality
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cd backend-plugin-sample
|
|
61
|
+
make requirements # install test deps
|
|
62
|
+
make test # pytest
|
|
63
|
+
make quality # lint
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Tests live in [`tests/`](./tests/).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
openedx_plugin_sample/__init__.py,sha256=E9fmKelIlLZ45iHYCtDbKUrTE3-52gChGfskhKc70TA,154
|
|
2
|
+
openedx_plugin_sample/admin.py,sha256=g0nbHkGT-SaFWxKbJXma1wJHBArThDyrFT7Hw_zSrc8,1838
|
|
3
|
+
openedx_plugin_sample/apps.py,sha256=h2fvV2lnRJPeJJmdrYO7FC6Ty9jr1O6MMlEQ7_o-H1M,5595
|
|
4
|
+
openedx_plugin_sample/models.py,sha256=e1Q-U1l_FHlTj1v7NTsX4KXyrp5bCF-JWl7hUXaW_7k,2220
|
|
5
|
+
openedx_plugin_sample/pipeline.py,sha256=FkF5Kgh6CmLO84MKfPyO78MyH8VLWhFZRroadmhwTR4,3886
|
|
6
|
+
openedx_plugin_sample/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
openedx_plugin_sample/serializers.py,sha256=w_n2SWRk6P2zzmDD_yNnnNzq8oE6a1epe_66gcOPWu0,1851
|
|
8
|
+
openedx_plugin_sample/signals.py,sha256=_j-ORn5Mo0WqJEt9LGXPb0_CBRN1tYCZkInL95YhJ0g,2815
|
|
9
|
+
openedx_plugin_sample/urls.py,sha256=SY9POVN6X1jPYfR_WmqJAGI3BCX5qvLTnS77-20pXag,517
|
|
10
|
+
openedx_plugin_sample/views.py,sha256=DW6R5XpB8LSLbuyuzl6hiXWJcqU8zj0GSTg5RacL-_Y,9233
|
|
11
|
+
openedx_plugin_sample/conf/locale/config.yaml,sha256=Rbk0_bjc9HRZTRQvzO58sKsdMIeP7EmhyeOO30l7dQ8,2281
|
|
12
|
+
openedx_plugin_sample/migrations/0001_initial.py,sha256=ZI9pSuPN7GuHFJmWMJnD5vaHj25WIq7ErUakHF0JAMA,1803
|
|
13
|
+
openedx_plugin_sample/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
openedx_plugin_sample/settings/common.py,sha256=RcMppJn0mmFS_ium1IxSHO5zMoO5X-VVJLqRnTNEDyY,5246
|
|
15
|
+
openedx_plugin_sample/settings/production.py,sha256=R1_L0P9cbSjKxhbaezTkcd0dxZox2cEYhpE0v47D34U,364
|
|
16
|
+
openedx_plugin_sample/settings/test.py,sha256=oYi7SuwG6K56IXGtR8GUfrajua7m2_2_sfHrgYYLop0,353
|
|
17
|
+
openedx_plugin_sample/templates/openedx_plugin_sample/base.html,sha256=NHmMV45xJTnPEKQltuhe5Ddw9MDZLIQD_rK8GRXhqrU,873
|
|
18
|
+
openedx_plugin_sample-3.8.0.dist-info/licenses/LICENSE.txt,sha256=_kYizHmx1l2Y2boQzMunfQZ7A1T8r_mzfiD83ObbafY,10177
|
|
19
|
+
openedx_plugin_sample-3.8.0.dist-info/METADATA,sha256=f6VHn5NO5vKL6Uo6GSJt-zi-obBvILPJsYsYdsMoIII,4456
|
|
20
|
+
openedx_plugin_sample-3.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
21
|
+
openedx_plugin_sample-3.8.0.dist-info/entry_points.txt,sha256=-h7tq5JL02glSCuvKRpX3lMI9tO8s7_SS6vIE5fxLJU,173
|
|
22
|
+
openedx_plugin_sample-3.8.0.dist-info/top_level.txt,sha256=hRO3yIbde1yryAI7pdB_9dzMyQf138iKO0DBEjQFXUg,22
|
|
23
|
+
openedx_plugin_sample-3.8.0.dist-info/RECORD,,
|