openedx-plugin-sample 3.0.0__py2.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,135 @@
1
+ """
2
+ Common settings for the openedx_plugin_sample application.
3
+
4
+ This module demonstrates how Django App Plugins integrate with the platform's
5
+ settings system. Plugin settings are merged with the main settings during
6
+ platform initialization.
7
+
8
+ Plugin Settings Integration:
9
+ The plugin_settings function is called during Django startup and receives
10
+ the main settings object. You can modify this object to add plugin-specific
11
+ configuration that integrates seamlessly with the platform.
12
+
13
+ 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
15
+ - Django Settings: https://docs.djangoproject.com/en/stable/topics/settings/
16
+
17
+ Settings Organization:
18
+ - common.py: Settings for all environments
19
+ - production.py: Production-specific overrides
20
+ - test.py: Test environment optimizations
21
+
22
+ Integration Points:
23
+ - OPEN_EDX_FILTERS_CONFIG: Register filters with the platform
24
+ - API rate limiting and throttling configuration
25
+ - Database connection settings for plugin models
26
+ - External service integration parameters
27
+ - Feature flags and environment-specific toggles
28
+ """ # noqa: E501
29
+
30
+ import logging
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def plugin_settings(settings):
36
+ """
37
+ Configure plugin-specific Django settings.
38
+
39
+ This function is called during Django startup to merge plugin settings
40
+ with the main platform configuration. All settings added here become
41
+ available throughout the Django application.
42
+
43
+ Args:
44
+ settings (dict): Main Django settings object to modify
45
+
46
+ Common Settings Patterns:
47
+
48
+ # Plugin-specific configuration
49
+ settings.SAMPLE_PLUGIN_API_RATE_LIMIT = "60/minute"
50
+ settings.SAMPLE_PLUGIN_ARCHIVE_RETENTION_DAYS = 365
51
+
52
+ # External service integration
53
+ settings.SAMPLE_PLUGIN_EXTERNAL_API_URL = "https://api.example.com"
54
+ settings.SAMPLE_PLUGIN_API_KEY = "your-api-key"
55
+
56
+ # Feature flags
57
+ settings.SAMPLE_PLUGIN_ENABLE_ARCHIVING = True
58
+ settings.SAMPLE_PLUGIN_ENABLE_NOTIFICATIONS = False
59
+
60
+ Environment-Specific Settings:
61
+ Different environment files can override these settings:
62
+ - production.py: Stricter rate limits, external API endpoints
63
+ - test.py: Faster timeouts, mock services, in-memory databases
64
+ - development.py: Debug logging, local service endpoints
65
+
66
+ Security Considerations:
67
+ - Never commit API keys or secrets to version control
68
+ - Use environment variables for sensitive configuration
69
+ - Validate setting values to prevent configuration errors
70
+ """
71
+ # Plugin is configured but no additional settings needed for this basic example
72
+ # Uncomment and modify the examples below for your use case:
73
+
74
+ # Plugin-specific configuration
75
+ # settings.SAMPLE_PLUGIN_API_RATE_LIMIT = "60/minute"
76
+ # settings.SAMPLE_PLUGIN_ARCHIVE_RETENTION_DAYS = 365
77
+
78
+ # Register Open edX Filters (additive approach)
79
+ _configure_openedx_filters(settings)
80
+
81
+
82
+ def _configure_openedx_filters(settings):
83
+ """
84
+ Configure Open edX Filters for the sample plugin.
85
+
86
+ This function demonstrates the proper way to register filters by:
87
+ 1. Preserving existing filter configuration from other plugins
88
+ 2. Adding our filter configuration additively
89
+ 3. Avoiding duplicate pipeline steps
90
+ 4. Logging configuration state for debugging
91
+
92
+ Args:
93
+ settings (dict): Django settings object
94
+ """
95
+ # Get existing filter configuration (may be from other plugins or platform)
96
+ filters_config = getattr(settings, 'OPEN_EDX_FILTERS_CONFIG', {})
97
+
98
+ # 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"
101
+
102
+ # Check if this filter already has configuration
103
+ if filter_name in filters_config:
104
+ logger.debug(f"Filter {filter_name} already configured, adding our pipeline step")
105
+
106
+ # Get existing pipeline steps
107
+ existing_pipeline = filters_config[filter_name].get("pipeline", [])
108
+
109
+ # Check if our pipeline step is already registered
110
+ if our_pipeline_step in existing_pipeline:
111
+ logger.info(
112
+ f"Pipeline step {our_pipeline_step} already registered for filter {filter_name}. "
113
+ "This may indicate the plugin is being loaded multiple times or another plugin "
114
+ "has registered the same pipeline step."
115
+ )
116
+ else:
117
+ # Add our pipeline step to existing configuration
118
+ existing_pipeline.append(our_pipeline_step)
119
+ filters_config[filter_name]["pipeline"] = existing_pipeline
120
+ logger.debug(f"Added {our_pipeline_step} to existing filter configuration")
121
+ else:
122
+ # Create new filter configuration
123
+ logger.debug(f"Creating new filter configuration for {filter_name}")
124
+ filters_config[filter_name] = {
125
+ "pipeline": [our_pipeline_step],
126
+ "fail_silently": False,
127
+ }
128
+
129
+ # Update the settings object
130
+ settings.OPEN_EDX_FILTERS_CONFIG = filters_config
131
+
132
+ logger.debug(
133
+ f"Final filter configuration for {filter_name}: "
134
+ f"{filters_config.get(filter_name, {})}"
135
+ )
@@ -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,132 @@
1
+ """
2
+ Open edX Events signal handlers for the openedx_plugin_sample application.
3
+
4
+ This module demonstrates how to consume Open edX Events (signals) to react to
5
+ platform activities and integrate with external systems. Events are part of
6
+ the Hooks Extension Framework and provide a stable way to extend Open edX.
7
+
8
+ What Are Open edX Events?
9
+ Events are signals sent when specific actions occur in the platform. Unlike
10
+ traditional Django signals, Open edX Events have standardized data structures
11
+ and are designed for external consumption.
12
+
13
+ Key Concepts:
14
+ - Events are fired at specific points in the platform lifecycle
15
+ - Each event includes structured data (defined in openedx-events)
16
+ - Event handlers can perform actions but cannot modify the event data
17
+ - Events support both internal processing and external event bus integration
18
+
19
+ Official Documentation:
20
+ - Events Overview: https://docs.openedx.org/projects/openedx-events/en/latest/
21
+ - Available Events: https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html
22
+ - Consuming Events: https://docs.openedx.org/projects/openedx-events/en/latest/how-tos/consume-an-event.html
23
+ - Hooks Framework: https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html
24
+
25
+ Registration Process:
26
+ 1. Import the event signal from openedx-events
27
+ 2. Create handler function with correct signature
28
+ 3. Decorate with @receiver
29
+ 4. Import this module in apps.py ready() method
30
+
31
+ Event Data Structure:
32
+ Each event defines specific data attributes. Check the event definition in the
33
+ official documentation to understand available data:
34
+ - Signal Reference: https://docs.openedx.org/projects/openedx-events/en/latest/reference/events.html
35
+ - Data Objects: https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html
36
+ - Example: COURSE_CATALOG_INFO_CHANGED provides catalog_info: CourseCatalogData
37
+
38
+ Common Use Cases:
39
+ - Integration with external systems (CRM, analytics, notifications)
40
+ - Custom logging and audit trails
41
+ - Triggering workflows in other services
42
+ - Synchronizing data with external databases
43
+ """
44
+
45
+ import logging
46
+
47
+ from django.dispatch import receiver
48
+ from openedx_events.content_authoring.data import CourseCatalogData
49
+ from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ @receiver(COURSE_CATALOG_INFO_CHANGED)
55
+ def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **kwargs): # pylint: disable=unused-argument # noqa: E501
56
+ """
57
+ Handle course catalog information changes.
58
+
59
+ This function demonstrates how to consume the COURSE_CATALOG_INFO_CHANGED event,
60
+ which is fired whenever course catalog information is updated in the platform.
61
+
62
+ Event Trigger Conditions:
63
+ - Course metadata is modified (name, description, etc.)
64
+ - Course schedule is updated
65
+ - Course visibility settings change
66
+ - Other catalog-related modifications
67
+
68
+ Args:
69
+ signal: The signal instance that triggered this handler
70
+ sender: The model class that sent the signal
71
+ catalog_info (CourseCatalogData): Structured data about the course
72
+ **kwargs: Additional context parameters
73
+
74
+ CourseCatalogData Attributes:
75
+ Based on the official data structure documentation:
76
+ https://docs.openedx.org/projects/openedx-events/en/latest/reference/data.html#openedx_events.content_authoring.data.CourseCatalogData
77
+
78
+ - course_key (CourseKey): Unique course identifier
79
+ - name (str): Course display name
80
+ - schedule (CourseScheduleData): Start/end dates and pacing
81
+ - hidden (bool): Course visibility status
82
+
83
+ Real-World Use Cases:
84
+ - Sync course metadata with external systems (CRM, marketing sites)
85
+ - Update search indexes when course information changes
86
+ - Trigger email notifications to administrators
87
+ - Log changes for audit and compliance
88
+ - Update analytics dashboards with new course information
89
+
90
+ Example Implementation::
91
+
92
+ # Send to external CRM system
93
+ external_api.update_course(
94
+ course_id=str(catalog_info.course_key),
95
+ name=catalog_info.name,
96
+ is_hidden=catalog_info.hidden
97
+ )
98
+
99
+ # Update internal tracking
100
+ CourseChangeLog.objects.create(
101
+ course_key=catalog_info.course_key,
102
+ change_type='catalog_updated',
103
+ timestamp=timezone.now()
104
+ )
105
+
106
+ Performance Considerations:
107
+ - Keep processing lightweight (events should not block platform operations)
108
+ - Use asynchronous tasks for heavy processing (Celery, etc.)
109
+ - Handle exceptions gracefully to prevent platform disruption
110
+ """
111
+ logging.info(f"Course catalog updated: {catalog_info.course_key}")
112
+
113
+ # Access available data from the event
114
+ logging.debug(f"Course name: {catalog_info.name}")
115
+ logging.debug(f"Course hidden: {catalog_info.hidden}")
116
+
117
+ # Example: Integrate with external systems
118
+ # try:
119
+ # # Send to external system
120
+ # external_system.notify_course_update(
121
+ # course_id=str(catalog_info.course_key),
122
+ # course_name=catalog_info.name,
123
+ # is_hidden=catalog_info.hidden
124
+ # )
125
+ # except Exception as e:
126
+ # logging.error(f"Failed to notify external system: {e}")
127
+
128
+ # Example: Update internal tracking
129
+ # from .models import CourseArchiveStatus
130
+ # CourseArchiveStatus.objects.filter(
131
+ # course_id=catalog_info.course_key
132
+ # ).update(last_catalog_update=timezone.now())
@@ -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,241 @@
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.rest_framework import DjangoFilterBackend
9
+ from opaque_keys import InvalidKeyError
10
+ from opaque_keys.edx.keys import CourseKey
11
+ from rest_framework import filters, permissions, viewsets
12
+ from rest_framework.exceptions import PermissionDenied, ValidationError
13
+ from rest_framework.pagination import PageNumberPagination
14
+ from rest_framework.throttling import UserRateThrottle
15
+
16
+ from openedx_plugin_sample.models import CourseArchiveStatus
17
+ from openedx_plugin_sample.serializers import CourseArchiveStatusSerializer
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class IsOwnerOrStaffSuperuser(permissions.BasePermission):
23
+ """
24
+ Custom permission to only allow owners of an object or staff/superusers to view or edit it.
25
+ """
26
+
27
+ def has_permission(self, request, view):
28
+ """
29
+ Return True if permission is granted to the view.
30
+ """
31
+ # Allow authenticated users to list and create
32
+ return request.user and request.user.is_authenticated
33
+
34
+ def has_object_permission(self, request, view, obj):
35
+ """
36
+ Return True if permission is granted to the object.
37
+ """
38
+ # Allow if the object belongs to the requesting user
39
+ if obj.user == request.user:
40
+ return True
41
+
42
+ # Allow staff users and superusers
43
+ if request.user.is_staff or request.user.is_superuser:
44
+ return True
45
+
46
+ return False
47
+
48
+
49
+ class CourseArchiveStatusPagination(PageNumberPagination):
50
+ """
51
+ Pagination class for CourseArchiveStatus.
52
+ """
53
+
54
+ page_size = 20
55
+ page_size_query_param = "page_size"
56
+ max_page_size = 100
57
+
58
+
59
+ class CourseArchiveStatusThrottle(UserRateThrottle):
60
+ """
61
+ Throttle for the CourseArchiveStatus API.
62
+ """
63
+
64
+ rate = "60/minute"
65
+
66
+
67
+ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
68
+ """
69
+ API viewset for CourseArchiveStatus.
70
+
71
+ Allows users to view their own course archive statuses and staff/superusers to view all.
72
+ Pagination is applied with a default page size of 20 (max 100).
73
+ Filtering is available on course_id, user, and is_archived fields.
74
+ Ordering is available on all fields.
75
+ """
76
+
77
+ serializer_class = CourseArchiveStatusSerializer
78
+ permission_classes = [IsOwnerOrStaffSuperuser]
79
+ pagination_class = CourseArchiveStatusPagination
80
+ throttle_classes = [
81
+ CourseArchiveStatusThrottle,
82
+ ]
83
+ filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
84
+ filterset_fields = ["course_id", "user", "is_archived"]
85
+ ordering_fields = [
86
+ "course_id",
87
+ "user",
88
+ "is_archived",
89
+ "archive_date",
90
+ "created_at",
91
+ "updated_at",
92
+ ]
93
+ ordering = ["-updated_at"]
94
+
95
+ def get_queryset(self):
96
+ """
97
+ Return the queryset for this viewset.
98
+
99
+ Regular users can only see their own records.
100
+ Staff and superusers can see all records but with optimized queries.
101
+ """
102
+ user = self.request.user
103
+
104
+ # Validate query parameters to prevent injection
105
+ self._validate_query_params()
106
+
107
+ # Always use select_related to avoid N+1 queries
108
+ base_queryset = CourseArchiveStatus.objects.select_related("user")
109
+
110
+ if user.is_staff or user.is_superuser:
111
+ return base_queryset
112
+
113
+ # Regular users only see their own records
114
+ return base_queryset.filter(user=user)
115
+
116
+ def _validate_query_params(self):
117
+ """
118
+ Validate query parameters to prevent injection.
119
+ """
120
+ # Example validation for course_id format
121
+ course_id = self.request.query_params.get("course_id")
122
+ if course_id and not self._is_valid_course_id(course_id):
123
+ logger.warning(
124
+ "Invalid course_id in request: %s, user: %s",
125
+ course_id,
126
+ self.request.user.username,
127
+ )
128
+ raise ValidationError({"course_id": "Invalid course ID format."})
129
+
130
+ def _is_valid_course_id(self, course_id):
131
+ """
132
+ Check if the course_id is in a valid format.
133
+
134
+ This is a basic implementation - in production, you might use a more
135
+ sophisticated validator from the edx-platform.
136
+ """
137
+ try:
138
+ CourseKey.from_string(course_id)
139
+ return True
140
+ except InvalidKeyError:
141
+ return False
142
+
143
+ def perform_create(self, serializer):
144
+ """
145
+ Perform creation of a new CourseArchiveStatus.
146
+
147
+ Validates permission for user override and sets archive_date if needed.
148
+ """
149
+ # Check if user was explicitly provided and differs from current user
150
+ if "user" in self.request.data:
151
+ requested_user_id = self.request.data["user"]
152
+ if requested_user_id != self.request.user.id and not (
153
+ self.request.user.is_staff or self.request.user.is_superuser
154
+ ):
155
+ logger.warning(
156
+ "Permission denied: User %s tried to create a record for user %s",
157
+ self.request.user.username,
158
+ requested_user_id,
159
+ )
160
+ raise PermissionDenied(
161
+ "You do not have permission to create records for other users."
162
+ )
163
+
164
+ # Set archive_date if is_archived is True
165
+ data = {}
166
+ if serializer.validated_data.get("is_archived", False):
167
+ data["archive_date"] = timezone.now()
168
+
169
+ # Create the record
170
+ instance = serializer.save(**data)
171
+
172
+ # Log at debug level for normal operation
173
+ logger.debug(
174
+ "CourseArchiveStatus created: course_id=%s, user=%s, is_archived=%s",
175
+ instance.course_id,
176
+ instance.user.username,
177
+ instance.is_archived,
178
+ )
179
+
180
+ return instance
181
+
182
+ def perform_update(self, serializer):
183
+ """
184
+ Perform update of an existing CourseArchiveStatus.
185
+
186
+ Validates permission for user override and updates archive_date if needed.
187
+ """
188
+ instance = serializer.instance
189
+
190
+ # Check if user was explicitly provided and differs from current user
191
+ if "user" in self.request.data:
192
+ requested_user_id = self.request.data["user"]
193
+ if requested_user_id != self.request.user.id and not (
194
+ self.request.user.is_staff or self.request.user.is_superuser
195
+ ):
196
+ logger.warning(
197
+ "Permission denied: User %s tried to update a record for user %s",
198
+ self.request.user.username,
199
+ requested_user_id,
200
+ )
201
+ raise PermissionDenied(
202
+ "You do not have permission to update records for other users."
203
+ )
204
+
205
+ # Handle archive_date if is_archived changes
206
+ data = {}
207
+ if "is_archived" in serializer.validated_data:
208
+ # If changing from not archived to archived
209
+ if serializer.validated_data["is_archived"] and not instance.is_archived:
210
+ data["archive_date"] = timezone.now()
211
+ # If changing from archived to not archived
212
+ elif not serializer.validated_data["is_archived"] and instance.is_archived:
213
+ data["archive_date"] = None
214
+
215
+ # Update the record
216
+ updated_instance = serializer.save(**data)
217
+
218
+ # Log at debug level
219
+ logger.debug(
220
+ "CourseArchiveStatus updated: course_id=%s, user=%s, is_archived=%s",
221
+ updated_instance.course_id,
222
+ updated_instance.user.username,
223
+ updated_instance.is_archived,
224
+ )
225
+
226
+ return updated_instance
227
+
228
+ def perform_destroy(self, instance):
229
+ """
230
+ Perform deletion of an existing CourseArchiveStatus.
231
+ """
232
+ # Log at debug level before deletion
233
+ logger.debug(
234
+ "CourseArchiveStatus deleted: course_id=%s, user=%s, by=%s",
235
+ instance.course_id,
236
+ instance.user.username,
237
+ self.request.user.username,
238
+ )
239
+
240
+ # Delete the instance
241
+ return super().perform_destroy(instance)