openedx-plugin-sample 3.1.0__tar.gz → 3.2.0__tar.gz

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.
Files changed (66) hide show
  1. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/.annotation_safe_list.yml +8 -0
  2. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/PKG-INFO +2 -1
  3. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/pyproject.toml +1 -0
  4. openedx_plugin_sample-3.2.0/src/openedx_plugin_sample/admin.py +55 -0
  5. openedx_plugin_sample-3.2.0/src/openedx_plugin_sample/migrations/0001_initial.py +36 -0
  6. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/models.py +11 -6
  7. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/pipeline.py +3 -2
  8. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/serializers.py +22 -0
  9. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/signals.py +1 -1
  10. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/views.py +41 -14
  11. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/PKG-INFO +2 -1
  12. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/SOURCES.txt +1 -0
  13. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/requires.txt +1 -0
  14. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/test_settings.py +2 -0
  15. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/tests/test_api.py +40 -16
  16. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/tests/test_models.py +19 -15
  17. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/tests/test_pipeline.py +14 -2
  18. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/tests/test_signals.py +14 -2
  19. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/uv.lock +354 -1
  20. openedx_plugin_sample-3.1.0/src/openedx_plugin_sample/migrations/0001_initial.py +0 -78
  21. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/.coveragerc +0 -0
  22. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/.editorconfig +0 -0
  23. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/.gitignore +0 -0
  24. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/.pii_annotations.yml +0 -0
  25. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/.readthedocs.yaml +0 -0
  26. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/LICENSE.txt +0 -0
  27. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/Makefile +0 -0
  28. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/README.md +0 -0
  29. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/codecov.yml +0 -0
  30. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/Makefile +0 -0
  31. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/_static/theme_overrides.css +0 -0
  32. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/concepts/index.rst +0 -0
  33. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/conf.py +0 -0
  34. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/decisions/0001-purpose-of-this-repo.rst +0 -0
  35. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/decisions/README.rst +0 -0
  36. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/decisions.rst +0 -0
  37. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/getting_started.rst +0 -0
  38. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/how-tos/index.rst +0 -0
  39. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/index.rst +0 -0
  40. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/internationalization.rst +0 -0
  41. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/make.bat +0 -0
  42. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/openedx_plugin_sample.rst +0 -0
  43. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/quickstarts/index.rst +0 -0
  44. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/references/index.rst +0 -0
  45. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/docs/testing.rst +0 -0
  46. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/manage.py +0 -0
  47. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/pylintrc +0 -0
  48. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/pylintrc_tweaks +0 -0
  49. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/setup.cfg +0 -0
  50. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/__init__.py +0 -0
  51. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/apps.py +0 -0
  52. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/conf/locale/config.yaml +0 -0
  53. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/migrations/__init__.py +0 -0
  54. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/py.typed +0 -0
  55. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/settings/common.py +0 -0
  56. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/settings/production.py +0 -0
  57. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/settings/test.py +0 -0
  58. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/templates/openedx_plugin_sample/base.html +0 -0
  59. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample/urls.py +0 -0
  60. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/dependency_links.txt +0 -0
  61. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/entry_points.txt +0 -0
  62. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/src/openedx_plugin_sample.egg-info/top_level.txt +0 -0
  63. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/test_utils/__init__.py +0 -0
  64. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/tests/test_plugin_integration.py +0 -0
  65. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/tests/urls.py +0 -0
  66. {openedx_plugin_sample-3.1.0 → openedx_plugin_sample-3.2.0}/tox.ini +0 -0
@@ -39,3 +39,11 @@ waffle.Sample:
39
39
  ".. no_pii:": "This model has no PII"
40
40
  waffle.Switch:
41
41
  ".. no_pii:": "This model has no PII"
42
+ openedx_catalog.CatalogCourse:
43
+ ".. no_pii:": "This model has no PII"
44
+ openedx_catalog.CourseRun:
45
+ ".. no_pii:": "This model has no PII"
46
+ organizations.HistoricalOrganization:
47
+ ".. no_pii:": "This model has no PII"
48
+ organizations.HistoricalOrganizationCourse:
49
+ ".. no_pii:": "This model has no PII"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openedx-plugin-sample
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: A sample backend plugin for the Open edX Platform
5
5
  Author-email: Open edX Project <oscm@openedx.org>
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,7 @@ Requires-Dist: Django
21
21
  Requires-Dist: djangorestframework
22
22
  Requires-Dist: django-filter
23
23
  Requires-Dist: edx-opaque-keys
24
+ Requires-Dist: openedx-core
24
25
  Requires-Dist: openedx-events
25
26
  Requires-Dist: openedx-filters
26
27
  Requires-Dist: openedx-atlas
@@ -32,6 +32,7 @@ dependencies = [
32
32
  "djangorestframework",
33
33
  "django-filter",
34
34
  "edx-opaque-keys",
35
+ "openedx-core",
35
36
  "openedx-events",
36
37
  "openedx-filters",
37
38
  "openedx-atlas",
@@ -0,0 +1,55 @@
1
+ """
2
+ Django admin configuration for openedx_plugin_sample.
3
+
4
+ This module demonstrates how to expose plugin models in the Django admin
5
+ site provided by Open edX (LMS and CMS each have their own admin under
6
+ ``/admin/``). Defining a ``ModelAdmin`` for each model gives operators a
7
+ ready-made UI to inspect and manage plugin data without needing custom
8
+ tooling.
9
+
10
+ Django Documentation:
11
+ - ModelAdmin: https://docs.djangoproject.com/en/stable/ref/contrib/admin/
12
+ """
13
+
14
+ from django.contrib import admin
15
+
16
+ from openedx_plugin_sample.models import CourseArchiveStatus
17
+
18
+
19
+ @admin.register(CourseArchiveStatus)
20
+ class CourseArchiveStatusAdmin(admin.ModelAdmin):
21
+ """
22
+ Admin configuration for the CourseArchiveStatus model.
23
+ """
24
+
25
+ list_display = (
26
+ "course_key",
27
+ "user",
28
+ "is_archived",
29
+ "archive_date",
30
+ "updated_at",
31
+ )
32
+ list_filter = ("is_archived",)
33
+ # Search by the related CourseRun's course_key and the user's username/email.
34
+ search_fields = (
35
+ "course_run__course_key",
36
+ "user__username",
37
+ "user__email",
38
+ )
39
+ # FKs use raw id widgets (lookup popup) rather than a <select>, since the
40
+ # CourseRun and User tables can have many thousands of rows on a real
41
+ # Open edX deployment.
42
+ raw_id_fields = ("course_run", "user")
43
+ readonly_fields = ("created_at", "updated_at")
44
+ ordering = ("-updated_at",)
45
+
46
+ @admin.display(description="Course key", ordering="course_run__course_key")
47
+ def course_key(self, obj: CourseArchiveStatus) -> str:
48
+ """
49
+ Show the course's course_key string in list_display.
50
+
51
+ We never expose CourseRun's internal integer PK in the admin; the
52
+ course_key (e.g. "course-v1:edX+DemoX+Demo_Course") is the identifier
53
+ operators recognize.
54
+ """
55
+ return str(obj.course_run.course_key)
@@ -0,0 +1,36 @@
1
+ # Generated by Django 5.2.13 on 2026-05-14 01:13
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ('openedx_catalog', '0001_initial'),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='CourseArchiveStatus',
20
+ fields=[
21
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('is_archived', models.BooleanField(db_index=True, default=False, help_text='Whether the course is archived.')),
23
+ ('archive_date', models.DateTimeField(blank=True, help_text='The date and time when the course was archived.', null=True)),
24
+ ('created_at', models.DateTimeField(auto_now_add=True)),
25
+ ('updated_at', models.DateTimeField(auto_now=True)),
26
+ ('course_run', models.ForeignKey(help_text='The course run that this archive status is for.', on_delete=django.db.models.deletion.CASCADE, related_name='archive_statuses', to='openedx_catalog.courserun')),
27
+ ('user', models.ForeignKey(help_text='The user who this archive status is for.', on_delete=django.db.models.deletion.CASCADE, related_name='course_archive_statuses', to=settings.AUTH_USER_MODEL)),
28
+ ],
29
+ options={
30
+ 'verbose_name': 'Course Archive Status',
31
+ 'verbose_name_plural': 'Course Archive Statuses',
32
+ 'ordering': ['-updated_at'],
33
+ 'constraints': [models.UniqueConstraint(fields=('course_run', 'user'), name='unique_user_course_archive_status')],
34
+ },
35
+ ),
36
+ ]
@@ -4,7 +4,7 @@ Database models for openedx_plugin_sample.
4
4
 
5
5
  from django.contrib.auth import get_user_model
6
6
  from django.db import models
7
- from opaque_keys.edx.django.models import CourseKeyField
7
+ from openedx_catalog.models import CourseRun
8
8
 
9
9
 
10
10
  class CourseArchiveStatus(models.Model):
@@ -16,8 +16,11 @@ class CourseArchiveStatus(models.Model):
16
16
  .. no_pii: This model does not store PII directly, only references to users via foreign keys.
17
17
  """
18
18
 
19
- course_id = CourseKeyField(
20
- max_length=255, db_index=True, help_text="The unique identifier for the course."
19
+ course_run = models.ForeignKey(
20
+ CourseRun,
21
+ on_delete=models.CASCADE,
22
+ related_name="archive_statuses",
23
+ help_text="The course run that this archive status is for.",
21
24
  )
22
25
 
23
26
  user = models.ForeignKey(
@@ -47,7 +50,9 @@ class CourseArchiveStatus(models.Model):
47
50
  Return a string representation of the course archive status.
48
51
  """
49
52
  # pylint: disable=no-member
50
- return f"{self.course_id} - {self.user.username} - {'Archived' if self.is_archived else 'Not Archived'}"
53
+ # Identify the course by its course_key string, never by the internal PK.
54
+ archived = "Archived" if self.is_archived else "Not Archived"
55
+ return f"{self.course_run.course_key} - {self.user.username} - {archived}"
51
56
 
52
57
  class Meta:
53
58
  """
@@ -57,9 +62,9 @@ class CourseArchiveStatus(models.Model):
57
62
  verbose_name = "Course Archive Status"
58
63
  verbose_name_plural = "Course Archive Statuses"
59
64
  ordering = ["-updated_at"]
60
- # Ensure combination of course_id and user is unique
65
+ # Ensure combination of course_run and user is unique
61
66
  constraints = [
62
67
  models.UniqueConstraint(
63
- fields=["course_id", "user"], name="unique_user_course_archive_status"
68
+ fields=["course_run", "user"], name="unique_user_course_archive_status"
64
69
  )
65
70
  ]
@@ -35,7 +35,7 @@ Common Use Cases:
35
35
  - Data transformation and validation
36
36
  - Integration with external systems
37
37
  - Custom business logic implementation
38
- """ # pylint: disable=line-too-long
38
+ """
39
39
 
40
40
  import logging
41
41
 
@@ -78,7 +78,8 @@ class AddArchiveStatusToLearnerHomeCourseRun(PipelineStep):
78
78
  return serialized_courserun
79
79
  try:
80
80
  is_archived_by_learner = CourseArchiveStatus.objects.get(
81
- user=request.user, course_id=serialized_courserun["courseId"]
81
+ user=request.user,
82
+ course_run__course_key=serialized_courserun["courseId"],
82
83
  ).is_archived
83
84
  except CourseArchiveStatus.DoesNotExist:
84
85
  is_archived_by_learner = False
@@ -3,6 +3,7 @@ Serializers for the openedx_plugin_sample app.
3
3
  """
4
4
 
5
5
  from django.contrib.auth import get_user_model
6
+ from openedx_catalog.models import CourseRun
6
7
  from rest_framework import serializers
7
8
 
8
9
  from openedx_plugin_sample.models import CourseArchiveStatus
@@ -21,6 +22,16 @@ class CourseArchiveStatusSerializer(serializers.ModelSerializer):
21
22
  required=False,
22
23
  )
23
24
 
25
+ # The model stores a FK to CourseRun, but APIs should identify courses by
26
+ # their full course key string (e.g. "course-v1:edX+DemoX+Demo_Course"),
27
+ # never by CourseRun's internal integer PK. The slug field looks up the
28
+ # related CourseRun by its `course_key` for both reads and writes.
29
+ course_id = serializers.SlugRelatedField(
30
+ source="course_run",
31
+ slug_field="course_key",
32
+ queryset=CourseRun.objects.all(),
33
+ )
34
+
24
35
  class Meta:
25
36
  """
26
37
  Meta class for CourseArchiveStatusSerializer.
@@ -37,3 +48,14 @@ class CourseArchiveStatusSerializer(serializers.ModelSerializer):
37
48
  "updated_at",
38
49
  ]
39
50
  read_only_fields = ["id", "created_at", "updated_at", "archive_date"]
51
+
52
+ def to_representation(self, instance):
53
+ """
54
+ Serialize the instance, casting course_id to a string.
55
+
56
+ CourseRun.course_key returns a CourseLocator (not a string), which the
57
+ default JSON encoder can't serialize, so we coerce to str on output.
58
+ """
59
+ data = super().to_representation(instance)
60
+ data["course_id"] = str(data["course_id"])
61
+ return data
@@ -54,7 +54,7 @@ def unarchive_on_verified_upgrade(
54
54
 
55
55
  updated = CourseArchiveStatus.objects.filter(
56
56
  user_id=enrollment.user.id,
57
- course_id=enrollment.course.course_key,
57
+ course_run__course_key=enrollment.course.course_key,
58
58
  is_archived=True,
59
59
  ).update(is_archived=False, archive_date=None)
60
60
 
@@ -5,6 +5,7 @@ Views for the openedx_plugin_sample app.
5
5
  import logging
6
6
 
7
7
  from django.utils import timezone
8
+ from django_filters import rest_framework as django_filters
8
9
  from django_filters.rest_framework import DjangoFilterBackend
9
10
  from opaque_keys import InvalidKeyError
10
11
  from opaque_keys.edx.keys import CourseKey
@@ -64,6 +65,39 @@ class CourseArchiveStatusThrottle(UserRateThrottle):
64
65
  rate = "60/minute"
65
66
 
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
+
67
101
  class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
68
102
  """
69
103
  API viewset for CourseArchiveStatus.
@@ -81,15 +115,7 @@ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
81
115
  CourseArchiveStatusThrottle,
82
116
  ]
83
117
  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
- ]
118
+ filterset_class = CourseArchiveStatusFilterSet
93
119
  ordering = ["-updated_at"]
94
120
 
95
121
  def get_queryset(self):
@@ -104,8 +130,9 @@ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
104
130
  # Validate query parameters to prevent injection
105
131
  self._validate_query_params()
106
132
 
107
- # Always use select_related to avoid N+1 queries
108
- base_queryset = CourseArchiveStatus.objects.select_related("user")
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")
109
136
 
110
137
  if user.is_staff or user.is_superuser:
111
138
  return base_queryset
@@ -172,7 +199,7 @@ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
172
199
  # Log at debug level for normal operation
173
200
  logger.debug(
174
201
  "CourseArchiveStatus created: course_id=%s, user=%s, is_archived=%s",
175
- instance.course_id,
202
+ instance.course_run.course_key,
176
203
  instance.user.username,
177
204
  instance.is_archived,
178
205
  )
@@ -218,7 +245,7 @@ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
218
245
  # Log at debug level
219
246
  logger.debug(
220
247
  "CourseArchiveStatus updated: course_id=%s, user=%s, is_archived=%s",
221
- updated_instance.course_id,
248
+ updated_instance.course_run.course_key,
222
249
  updated_instance.user.username,
223
250
  updated_instance.is_archived,
224
251
  )
@@ -232,7 +259,7 @@ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
232
259
  # Log at debug level before deletion
233
260
  logger.debug(
234
261
  "CourseArchiveStatus deleted: course_id=%s, user=%s, by=%s",
235
- instance.course_id,
262
+ instance.course_run.course_key,
236
263
  instance.user.username,
237
264
  self.request.user.username,
238
265
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openedx-plugin-sample
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: A sample backend plugin for the Open edX Platform
5
5
  Author-email: Open edX Project <oscm@openedx.org>
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,7 @@ Requires-Dist: Django
21
21
  Requires-Dist: djangorestframework
22
22
  Requires-Dist: django-filter
23
23
  Requires-Dist: edx-opaque-keys
24
+ Requires-Dist: openedx-core
24
25
  Requires-Dist: openedx-events
25
26
  Requires-Dist: openedx-filters
26
27
  Requires-Dist: openedx-atlas
@@ -33,6 +33,7 @@ docs/how-tos/index.rst
33
33
  docs/quickstarts/index.rst
34
34
  docs/references/index.rst
35
35
  src/openedx_plugin_sample/__init__.py
36
+ src/openedx_plugin_sample/admin.py
36
37
  src/openedx_plugin_sample/apps.py
37
38
  src/openedx_plugin_sample/models.py
38
39
  src/openedx_plugin_sample/pipeline.py
@@ -2,6 +2,7 @@ Django
2
2
  djangorestframework
3
3
  django-filter
4
4
  edx-opaque-keys
5
+ openedx-core
5
6
  openedx-events
6
7
  openedx-filters
7
8
  openedx-atlas
@@ -47,6 +47,8 @@ INSTALLED_APPS = [
47
47
  "django_filters",
48
48
  "edx_django_utils.plugins",
49
49
  "django_extensions",
50
+ "organizations",
51
+ "openedx_catalog",
50
52
  ]
51
53
 
52
54
  # Dynamically add plugin apps - only using the LMS context for simplicity
@@ -8,6 +8,7 @@ import pytest
8
8
  from django.contrib.auth import get_user_model
9
9
  from django.urls import reverse
10
10
  from opaque_keys.edx.keys import CourseKey
11
+ from openedx_catalog.api import create_course_run_for_modulestore_course_with
11
12
  from rest_framework import status
12
13
  from rest_framework.test import APIClient
13
14
 
@@ -70,12 +71,24 @@ def course_key():
70
71
 
71
72
 
72
73
  @pytest.fixture
73
- def course_archive_status(user, course_key):
74
+ def course_run(course_key):
75
+ """
76
+ Create and return a test CourseRun (plus its Organization and CatalogCourse)
77
+ matching the `course_key` fixture, so that API requests that POST/PATCH the
78
+ public `course_id` string can resolve it to this CourseRun.
79
+ """
80
+ return create_course_run_for_modulestore_course_with(
81
+ course_key, title="Demo Course"
82
+ )
83
+
84
+
85
+ @pytest.fixture
86
+ def course_archive_status(user, course_run):
74
87
  """
75
88
  Create and return a test course archive status.
76
89
  """
77
90
  return CourseArchiveStatus.objects.create(
78
- course_id=course_key, user=user, is_archived=False
91
+ course_run=course_run, user=user, is_archived=False
79
92
  )
80
93
 
81
94
 
@@ -93,7 +106,7 @@ def test_list_course_archive_status_authenticated(
93
106
  assert response.status_code == status.HTTP_200_OK
94
107
  assert response.data["count"] == 1
95
108
  assert response.data["results"][0]["course_id"] == str(
96
- course_archive_status.course_id
109
+ course_archive_status.course_run.course_key
97
110
  )
98
111
  assert response.data["results"][0]["user"] == user.id
99
112
  assert (
@@ -114,17 +127,23 @@ def test_list_course_archive_status_unauthenticated(api_client):
114
127
 
115
128
  @pytest.mark.django_db
116
129
  def test_list_course_archive_status_staff_can_see_all(
117
- api_client, staff_user, user, another_user, course_key
130
+ api_client, staff_user, user, another_user, course_run
118
131
  ):
119
132
  """
120
133
  Test that a staff user can list all course archive statuses.
121
134
  """
135
+ # A second CourseRun (with its own CatalogCourse/Organization) for the second status.
136
+ course_run_2 = create_course_run_for_modulestore_course_with(
137
+ CourseKey.from_string("course-v1:edX+DemoX+Demo_Course2"),
138
+ title="Demo Course 2",
139
+ )
140
+
122
141
  # Create archive statuses for both users
123
142
  CourseArchiveStatus.objects.create(
124
- course_id=course_key, user=user, is_archived=False
143
+ course_run=course_run, user=user, is_archived=False
125
144
  )
126
145
  CourseArchiveStatus.objects.create(
127
- course_id=CourseKey.from_string("course-v1:edX+DemoX+Demo_Course2"),
146
+ course_run=course_run_2,
128
147
  user=another_user,
129
148
  is_archived=True,
130
149
  )
@@ -138,7 +157,7 @@ def test_list_course_archive_status_staff_can_see_all(
138
157
 
139
158
 
140
159
  @pytest.mark.django_db
141
- def test_create_course_archive_status(api_client, user, course_key):
160
+ def test_create_course_archive_status(api_client, user, course_key, course_run):
142
161
  """
143
162
  Test that a user can create a course archive status.
144
163
  """
@@ -159,13 +178,14 @@ def test_create_course_archive_status(api_client, user, course_key):
159
178
 
160
179
  # Verify in database
161
180
  course_archive_status = CourseArchiveStatus.objects.get(
162
- course_id=course_key, user=user
181
+ course_run=course_run, user=user
163
182
  )
164
183
  assert course_archive_status.is_archived is True
165
184
  assert course_archive_status.archive_date is not None
166
185
 
167
186
 
168
187
  @pytest.mark.django_db
188
+ @pytest.mark.usefixtures("course_run")
169
189
  def test_create_course_archive_status_for_another_user(
170
190
  api_client, user, another_user, course_key
171
191
  ):
@@ -185,6 +205,7 @@ def test_create_course_archive_status_for_another_user(
185
205
 
186
206
 
187
207
  @pytest.mark.django_db
208
+ @pytest.mark.usefixtures("course_run")
188
209
  def test_staff_create_course_archive_status_for_another_user(
189
210
  api_client, staff_user, user, course_key
190
211
  ):
@@ -281,7 +302,9 @@ def test_staff_can_update_other_user_course_archive_status(
281
302
 
282
303
  # New tests for optional user field behavior
283
304
  @pytest.mark.django_db
284
- def test_create_course_archive_status_without_user_field(api_client, user, course_key):
305
+ def test_create_course_archive_status_without_user_field(
306
+ api_client, user, course_key, course_run
307
+ ):
285
308
  """
286
309
  Test that a user can create a course archive status without specifying user field.
287
310
  The user field should default to the current user.
@@ -307,7 +330,7 @@ def test_create_course_archive_status_without_user_field(api_client, user, cours
307
330
 
308
331
  # Verify in database
309
332
  course_archive_status = CourseArchiveStatus.objects.get(
310
- course_id=course_key, user=user
333
+ course_run=course_run, user=user
311
334
  )
312
335
  assert course_archive_status.is_archived is True
313
336
  assert course_archive_status.user == user
@@ -340,7 +363,7 @@ def test_update_course_archive_status_without_user_field(api_client, user, cours
340
363
 
341
364
  @pytest.mark.django_db
342
365
  def test_staff_create_with_explicit_user_override(
343
- api_client, staff_user, user, course_key
366
+ api_client, staff_user, user, course_key, course_run
344
367
  ):
345
368
  """
346
369
  Test that staff can explicitly set user field to override default behavior.
@@ -361,7 +384,7 @@ def test_staff_create_with_explicit_user_override(
361
384
 
362
385
  # Verify in database
363
386
  course_archive_status = CourseArchiveStatus.objects.get(
364
- course_id=course_key, user=user
387
+ course_run=course_run, user=user
365
388
  )
366
389
  assert course_archive_status.user == user
367
390
  assert course_archive_status.user != staff_user
@@ -369,14 +392,14 @@ def test_staff_create_with_explicit_user_override(
369
392
 
370
393
  @pytest.mark.django_db
371
394
  def test_staff_update_with_explicit_user_override(
372
- api_client, staff_user, user, another_user, course_key
395
+ api_client, staff_user, user, another_user, course_run
373
396
  ):
374
397
  """
375
398
  Test that staff can explicitly change user field when updating.
376
399
  """
377
400
  # Create initial record for user
378
401
  initial_status = CourseArchiveStatus.objects.create(
379
- course_id=course_key, user=user, is_archived=False
402
+ course_run=course_run, user=user, is_archived=False
380
403
  )
381
404
 
382
405
  api_client.force_authenticate(user=staff_user)
@@ -400,6 +423,7 @@ def test_staff_update_with_explicit_user_override(
400
423
 
401
424
 
402
425
  @pytest.mark.django_db
426
+ @pytest.mark.usefixtures("course_run")
403
427
  def test_regular_user_cannot_override_user_field_create(
404
428
  api_client, user, another_user, course_key
405
429
  ):
@@ -420,7 +444,7 @@ def test_regular_user_cannot_override_user_field_create(
420
444
 
421
445
  @pytest.mark.django_db
422
446
  def test_staff_create_without_user_field_defaults_to_current_user(
423
- api_client, staff_user, course_key
447
+ api_client, staff_user, course_key, course_run
424
448
  ):
425
449
  """
426
450
  Test that even staff users get records created for themselves when no user specified.
@@ -441,6 +465,6 @@ def test_staff_create_without_user_field_defaults_to_current_user(
441
465
 
442
466
  # Verify in database
443
467
  course_archive_status = CourseArchiveStatus.objects.get(
444
- course_id=course_key, user=staff_user
468
+ course_run=course_run, user=staff_user
445
469
  )
446
470
  assert course_archive_status.user == staff_user
@@ -8,6 +8,7 @@ import pytest
8
8
  from django.contrib.auth import get_user_model
9
9
  from django.db.utils import IntegrityError
10
10
  from opaque_keys.edx.keys import CourseKey
11
+ from openedx_catalog.api import create_course_run_for_modulestore_course_with
11
12
 
12
13
  from openedx_plugin_sample.models import CourseArchiveStatus
13
14
 
@@ -38,24 +39,27 @@ def staff_user():
38
39
 
39
40
 
40
41
  @pytest.fixture
41
- def course_key():
42
+ def course_run():
42
43
  """
43
- Create and return a test course key.
44
+ Create and return a test CourseRun (plus its Organization and CatalogCourse).
44
45
  """
45
- return CourseKey.from_string("course-v1:edX+DemoX+Demo_Course")
46
+ return create_course_run_for_modulestore_course_with(
47
+ CourseKey.from_string("course-v1:edX+DemoX+Demo_Course"),
48
+ title="Demo Course",
49
+ )
46
50
 
47
51
 
48
52
  @pytest.mark.django_db
49
- def test_course_archive_status_creation(user, course_key):
53
+ def test_course_archive_status_creation(user, course_run):
50
54
  """
51
55
  Test that a CourseArchiveStatus can be created with valid data.
52
56
  """
53
57
  course_archive_status = CourseArchiveStatus.objects.create(
54
- course_id=course_key, user=user, is_archived=False
58
+ course_run=course_run, user=user, is_archived=False
55
59
  )
56
60
 
57
61
  assert course_archive_status.pk is not None
58
- assert course_archive_status.course_id == course_key
62
+ assert course_archive_status.course_run == course_run
59
63
  assert course_archive_status.user == user
60
64
  assert course_archive_status.is_archived is False
61
65
  assert course_archive_status.archive_date is None
@@ -64,35 +68,35 @@ def test_course_archive_status_creation(user, course_key):
64
68
 
65
69
 
66
70
  @pytest.mark.django_db
67
- def test_course_archive_status_uniqueness(user, course_key):
71
+ def test_course_archive_status_uniqueness(user, course_run):
68
72
  """
69
- Test that a CourseArchiveStatus must be unique per user and course_id.
73
+ Test that a CourseArchiveStatus must be unique per user and course_run.
70
74
  """
71
75
  CourseArchiveStatus.objects.create(
72
- course_id=course_key, user=user, is_archived=False
76
+ course_run=course_run, user=user, is_archived=False
73
77
  )
74
78
 
75
- # Creating another with same user and course_id should raise an IntegrityError
79
+ # Creating another with same user and course_run should raise an IntegrityError
76
80
  with pytest.raises(IntegrityError):
77
81
  CourseArchiveStatus.objects.create(
78
- course_id=course_key, user=user, is_archived=True
82
+ course_run=course_run, user=user, is_archived=True
79
83
  )
80
84
 
81
85
 
82
86
  @pytest.mark.django_db
83
- def test_course_archive_status_str_method(user, course_key):
87
+ def test_course_archive_status_str_method(user, course_run):
84
88
  """
85
89
  Test the string representation of CourseArchiveStatus.
86
90
  """
87
91
  course_archive_status = CourseArchiveStatus.objects.create(
88
- course_id=course_key, user=user, is_archived=True
92
+ course_run=course_run, user=user, is_archived=True
89
93
  )
90
94
 
91
- expected_str = f"{course_key} - {user.username} - Archived"
95
+ expected_str = f"{course_run.course_key} - {user.username} - Archived"
92
96
  assert str(course_archive_status) == expected_str
93
97
 
94
98
  course_archive_status.is_archived = False
95
99
  course_archive_status.save()
96
100
 
97
- expected_str = f"{course_key} - {user.username} - Not Archived"
101
+ expected_str = f"{course_run.course_key} - {user.username} - Not Archived"
98
102
  assert str(course_archive_status) == expected_str