Skip to content

Commit 487b76e

Browse files
kdmccormickclaude
andcommitted
refactor: CourseArchiveStatus references CourseRun via FK
Replace the CourseKeyField on CourseArchiveStatus with a ForeignKey to openedx_catalog.CourseRun, following the new "use a FK to CourseRun for any model-level course reference" guidance from openedx-core. The public API still identifies courses by their course_key string (never by CourseRun's internal PK), so frontend consumers and the JSON contract are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 913f1b1 commit 487b76e

10 files changed

Lines changed: 562 additions & 166 deletions

File tree

backend-plugin-sample/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies = [
3232
"djangorestframework",
3333
"django-filter",
3434
"edx-opaque-keys",
35+
"openedx-core",
3536
"openedx-events",
3637
"openedx-filters",
3738
"openedx-atlas",
Lines changed: 15 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,36 @@
1-
# Generated by Django 4.2.20 on 2025-04-14 12:39
1+
# Generated by Django 5.2.13 on 2026-05-14 01:13
22

3+
import django.db.models.deletion
34
from django.conf import settings
45
from django.db import migrations, models
5-
import django.db.models.deletion
6-
import opaque_keys.edx.django.models
76

87

98
class Migration(migrations.Migration):
109

1110
initial = True
1211

1312
dependencies = [
13+
('openedx_catalog', '0001_initial'),
1414
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
1515
]
1616

1717
operations = [
1818
migrations.CreateModel(
19-
name="CourseArchiveStatus",
19+
name='CourseArchiveStatus',
2020
fields=[
21-
(
22-
"id",
23-
models.BigAutoField(
24-
auto_created=True,
25-
primary_key=True,
26-
serialize=False,
27-
verbose_name="ID",
28-
),
29-
),
30-
(
31-
"course_id",
32-
opaque_keys.edx.django.models.CourseKeyField(
33-
db_index=True,
34-
help_text="The unique identifier for the course.",
35-
max_length=255,
36-
),
37-
),
38-
(
39-
"is_archived",
40-
models.BooleanField(
41-
db_index=True,
42-
default=False,
43-
help_text="Whether the course is archived.",
44-
),
45-
),
46-
(
47-
"archive_date",
48-
models.DateTimeField(
49-
blank=True,
50-
help_text="The date and time when the course was archived.",
51-
null=True,
52-
),
53-
),
54-
("created_at", models.DateTimeField(auto_now_add=True)),
55-
("updated_at", models.DateTimeField(auto_now=True)),
56-
(
57-
"user",
58-
models.ForeignKey(
59-
help_text="The user who this archive status is for.",
60-
on_delete=django.db.models.deletion.CASCADE,
61-
related_name="course_archive_statuses",
62-
to=settings.AUTH_USER_MODEL,
63-
),
64-
),
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)),
6528
],
6629
options={
67-
"verbose_name": "Course Archive Status",
68-
"verbose_name_plural": "Course Archive Statuses",
69-
"ordering": ["-updated_at"],
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')],
7034
},
7135
),
72-
migrations.AddConstraint(
73-
model_name="coursearchivestatus",
74-
constraint=models.UniqueConstraint(
75-
fields=("course_id", "user"), name="unique_user_course_archive_status"
76-
),
77-
),
7836
]

backend-plugin-sample/src/openedx_plugin_sample/models.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from django.contrib.auth import get_user_model
66
from django.db import models
7-
from opaque_keys.edx.django.models import CourseKeyField
7+
from openedx_catalog.models import CourseRun
88

99

1010
class CourseArchiveStatus(models.Model):
@@ -16,8 +16,11 @@ class CourseArchiveStatus(models.Model):
1616
.. no_pii: This model does not store PII directly, only references to users via foreign keys.
1717
"""
1818

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.",
2124
)
2225

2326
user = models.ForeignKey(
@@ -47,7 +50,9 @@ def __str__(self):
4750
Return a string representation of the course archive status.
4851
"""
4952
# 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}"
5156

5257
class Meta:
5358
"""
@@ -57,9 +62,9 @@ class Meta:
5762
verbose_name = "Course Archive Status"
5863
verbose_name_plural = "Course Archive Statuses"
5964
ordering = ["-updated_at"]
60-
# Ensure combination of course_id and user is unique
65+
# Ensure combination of course_run and user is unique
6166
constraints = [
6267
models.UniqueConstraint(
63-
fields=["course_id", "user"], name="unique_user_course_archive_status"
68+
fields=["course_run", "user"], name="unique_user_course_archive_status"
6469
)
6570
]

backend-plugin-sample/src/openedx_plugin_sample/serializers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
from django.contrib.auth import get_user_model
6+
from openedx_catalog.models import CourseRun
67
from rest_framework import serializers
78

89
from openedx_plugin_sample.models import CourseArchiveStatus
@@ -21,6 +22,16 @@ class CourseArchiveStatusSerializer(serializers.ModelSerializer):
2122
required=False,
2223
)
2324

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+
2435
class Meta:
2536
"""
2637
Meta class for CourseArchiveStatusSerializer.
@@ -37,3 +48,14 @@ class Meta:
3748
"updated_at",
3849
]
3950
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

backend-plugin-sample/src/openedx_plugin_sample/signals.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,5 @@ def log_course_info_changed(signal, sender, catalog_info: CourseCatalogData, **k
128128
# Example: Update internal tracking
129129
# from .models import CourseArchiveStatus
130130
# CourseArchiveStatus.objects.filter(
131-
# course_id=catalog_info.course_key
131+
# course_run__course_key=catalog_info.course_key
132132
# ).update(last_catalog_update=timezone.now())

backend-plugin-sample/src/openedx_plugin_sample/views.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66

77
from django.utils import timezone
8+
from django_filters import rest_framework as django_filters
89
from django_filters.rest_framework import DjangoFilterBackend
910
from opaque_keys import InvalidKeyError
1011
from opaque_keys.edx.keys import CourseKey
@@ -64,6 +65,39 @@ class CourseArchiveStatusThrottle(UserRateThrottle):
6465
rate = "60/minute"
6566

6667

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+
67101
class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
68102
"""
69103
API viewset for CourseArchiveStatus.
@@ -81,15 +115,7 @@ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
81115
CourseArchiveStatusThrottle,
82116
]
83117
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
93119
ordering = ["-updated_at"]
94120

95121
def get_queryset(self):
@@ -104,8 +130,9 @@ def get_queryset(self):
104130
# Validate query parameters to prevent injection
105131
self._validate_query_params()
106132

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")
109136

110137
if user.is_staff or user.is_superuser:
111138
return base_queryset
@@ -172,7 +199,7 @@ def perform_create(self, serializer):
172199
# Log at debug level for normal operation
173200
logger.debug(
174201
"CourseArchiveStatus created: course_id=%s, user=%s, is_archived=%s",
175-
instance.course_id,
202+
instance.course_run.course_key,
176203
instance.user.username,
177204
instance.is_archived,
178205
)
@@ -218,7 +245,7 @@ def perform_update(self, serializer):
218245
# Log at debug level
219246
logger.debug(
220247
"CourseArchiveStatus updated: course_id=%s, user=%s, is_archived=%s",
221-
updated_instance.course_id,
248+
updated_instance.course_run.course_key,
222249
updated_instance.user.username,
223250
updated_instance.is_archived,
224251
)
@@ -232,7 +259,7 @@ def perform_destroy(self, instance):
232259
# Log at debug level before deletion
233260
logger.debug(
234261
"CourseArchiveStatus deleted: course_id=%s, user=%s, by=%s",
235-
instance.course_id,
262+
instance.course_run.course_key,
236263
instance.user.username,
237264
self.request.user.username,
238265
)

backend-plugin-sample/test_settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ def root(*args):
4747
"django_filters",
4848
"edx_django_utils.plugins",
4949
"django_extensions",
50+
"organizations",
51+
"openedx_catalog",
5052
]
5153

5254
# Dynamically add plugin apps - only using the LMS context for simplicity

0 commit comments

Comments
 (0)