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.
- openedx_plugin_sample/__init__.py +7 -0
- openedx_plugin_sample/apps.py +134 -0
- openedx_plugin_sample/conf/locale/config.yaml +85 -0
- openedx_plugin_sample/migrations/0001_initial.py +78 -0
- openedx_plugin_sample/migrations/__init__.py +0 -0
- openedx_plugin_sample/models.py +65 -0
- openedx_plugin_sample/pipeline.py +156 -0
- openedx_plugin_sample/py.typed +0 -0
- openedx_plugin_sample/serializers.py +39 -0
- openedx_plugin_sample/settings/common.py +135 -0
- openedx_plugin_sample/settings/production.py +16 -0
- openedx_plugin_sample/settings/test.py +17 -0
- openedx_plugin_sample/signals.py +132 -0
- openedx_plugin_sample/templates/openedx_plugin_sample/base.html +26 -0
- openedx_plugin_sample/urls.py +21 -0
- openedx_plugin_sample/views.py +241 -0
- openedx_plugin_sample-3.0.0.dist-info/METADATA +579 -0
- openedx_plugin_sample-3.0.0.dist-info/RECORD +22 -0
- openedx_plugin_sample-3.0.0.dist-info/WHEEL +6 -0
- openedx_plugin_sample-3.0.0.dist-info/entry_points.txt +5 -0
- openedx_plugin_sample-3.0.0.dist-info/licenses/LICENSE.txt +180 -0
- openedx_plugin_sample-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|