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.
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [cms.djangoapp]
2
+ openedx_plugin_sample = openedx_plugin_sample.apps:SamplePluginConfig
3
+
4
+ [lms.djangoapp]
5
+ openedx_plugin_sample = openedx_plugin_sample.apps:SamplePluginConfig