Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend-plugin-sample/.annotation_safe_list.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,11 @@ waffle.Sample:
".. no_pii:": "This model has no PII"
waffle.Switch:
".. no_pii:": "This model has no PII"
openedx_catalog.CatalogCourse:
".. no_pii:": "This model has no PII"
openedx_catalog.CourseRun:
".. no_pii:": "This model has no PII"
organizations.HistoricalOrganization:
".. no_pii:": "This model has no PII"
organizations.HistoricalOrganizationCourse:
".. no_pii:": "This model has no PII"
1 change: 1 addition & 0 deletions backend-plugin-sample/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"djangorestframework",
"django-filter",
"edx-opaque-keys",
"openedx-core",
"openedx-events",
"openedx-filters",
"openedx-atlas",
Expand Down
55 changes: 55 additions & 0 deletions backend-plugin-sample/src/openedx_plugin_sample/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Django admin configuration for openedx_plugin_sample.

This module demonstrates how to expose plugin models in the Django admin
site provided by Open edX (LMS and CMS each have their own admin under
``/admin/``). Defining a ``ModelAdmin`` for each model gives operators a
ready-made UI to inspect and manage plugin data without needing custom
tooling.

Django Documentation:
- ModelAdmin: https://docs.djangoproject.com/en/stable/ref/contrib/admin/
"""

from django.contrib import admin

from openedx_plugin_sample.models import CourseArchiveStatus


@admin.register(CourseArchiveStatus)
class CourseArchiveStatusAdmin(admin.ModelAdmin):
"""
Admin configuration for the CourseArchiveStatus model.
"""

list_display = (
"course_key",
"user",
"is_archived",
"archive_date",
"updated_at",
)
list_filter = ("is_archived",)
# Search by the related CourseRun's course_key and the user's username/email.
search_fields = (
"course_run__course_key",
"user__username",
"user__email",
)
# FKs use raw id widgets (lookup popup) rather than a <select>, since the
# CourseRun and User tables can have many thousands of rows on a real
# Open edX deployment.
raw_id_fields = ("course_run", "user")
readonly_fields = ("created_at", "updated_at")
ordering = ("-updated_at",)

@admin.display(description="Course key", ordering="course_run__course_key")
def course_key(self, obj: CourseArchiveStatus) -> str:
"""
Show the course's course_key string in list_display.

We never expose CourseRun's internal integer PK in the admin; the
course_key (e.g. "course-v1:edX+DemoX+Demo_Course") is the identifier
operators recognize.
"""
return str(obj.course_run.course_key)
Original file line number Diff line number Diff line change
@@ -1,78 +1,36 @@
# Generated by Django 4.2.20 on 2025-04-14 12:39
# Generated by Django 5.2.13 on 2026-05-14 01:13

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import opaque_keys.edx.django.models


class Migration(migrations.Migration):

initial = True

dependencies = [
('openedx_catalog', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="CourseArchiveStatus",
name='CourseArchiveStatus',
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"course_id",
opaque_keys.edx.django.models.CourseKeyField(
db_index=True,
help_text="The unique identifier for the course.",
max_length=255,
),
),
(
"is_archived",
models.BooleanField(
db_index=True,
default=False,
help_text="Whether the course is archived.",
),
),
(
"archive_date",
models.DateTimeField(
blank=True,
help_text="The date and time when the course was archived.",
null=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"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,
),
),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_archived', models.BooleanField(db_index=True, default=False, help_text='Whether the course is archived.')),
('archive_date', models.DateTimeField(blank=True, help_text='The date and time when the course was archived.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('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')),
('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)),
],
options={
"verbose_name": "Course Archive Status",
"verbose_name_plural": "Course Archive Statuses",
"ordering": ["-updated_at"],
'verbose_name': 'Course Archive Status',
'verbose_name_plural': 'Course Archive Statuses',
'ordering': ['-updated_at'],
'constraints': [models.UniqueConstraint(fields=('course_run', 'user'), name='unique_user_course_archive_status')],
},
),
migrations.AddConstraint(
model_name="coursearchivestatus",
constraint=models.UniqueConstraint(
fields=("course_id", "user"), name="unique_user_course_archive_status"
),
),
]
17 changes: 11 additions & 6 deletions backend-plugin-sample/src/openedx_plugin_sample/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from django.contrib.auth import get_user_model
from django.db import models
from opaque_keys.edx.django.models import CourseKeyField
from openedx_catalog.models import CourseRun


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

course_id = CourseKeyField(
max_length=255, db_index=True, help_text="The unique identifier for the course."
course_run = models.ForeignKey(
CourseRun,
on_delete=models.CASCADE,
related_name="archive_statuses",
help_text="The course run that this archive status is for.",
)

user = models.ForeignKey(
Expand Down Expand Up @@ -47,7 +50,9 @@ def __str__(self):
Return a string representation of the course archive status.
"""
# pylint: disable=no-member
return f"{self.course_id} - {self.user.username} - {'Archived' if self.is_archived else 'Not Archived'}"
# Identify the course by its course_key string, never by the internal PK.
archived = "Archived" if self.is_archived else "Not Archived"
return f"{self.course_run.course_key} - {self.user.username} - {archived}"

class Meta:
"""
Expand All @@ -57,9 +62,9 @@ class Meta:
verbose_name = "Course Archive Status"
verbose_name_plural = "Course Archive Statuses"
ordering = ["-updated_at"]
# Ensure combination of course_id and user is unique
# Ensure combination of course_run and user is unique
constraints = [
models.UniqueConstraint(
fields=["course_id", "user"], name="unique_user_course_archive_status"
fields=["course_run", "user"], name="unique_user_course_archive_status"
)
]
5 changes: 3 additions & 2 deletions backend-plugin-sample/src/openedx_plugin_sample/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
- Data transformation and validation
- Integration with external systems
- Custom business logic implementation
""" # pylint: disable=line-too-long
"""

import logging

Expand Down Expand Up @@ -78,7 +78,8 @@ def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=argumen
return serialized_courserun
try:
is_archived_by_learner = CourseArchiveStatus.objects.get(
user=request.user, course_id=serialized_courserun["courseId"]
user=request.user,
course_run__course_key=serialized_courserun["courseId"],
).is_archived
except CourseArchiveStatus.DoesNotExist:
is_archived_by_learner = False
Expand Down
22 changes: 22 additions & 0 deletions backend-plugin-sample/src/openedx_plugin_sample/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from django.contrib.auth import get_user_model
from openedx_catalog.models import CourseRun
from rest_framework import serializers

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

# The model stores a FK to CourseRun, but APIs should identify courses by
# their full course key string (e.g. "course-v1:edX+DemoX+Demo_Course"),
# never by CourseRun's internal integer PK. The slug field looks up the
# related CourseRun by its `course_key` for both reads and writes.
course_id = serializers.SlugRelatedField(
source="course_run",
slug_field="course_key",
queryset=CourseRun.objects.all(),
)

class Meta:
"""
Meta class for CourseArchiveStatusSerializer.
Expand All @@ -37,3 +48,14 @@ class Meta:
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at", "archive_date"]

def to_representation(self, instance):
"""
Serialize the instance, casting course_id to a string.

CourseRun.course_key returns a CourseLocator (not a string), which the
default JSON encoder can't serialize, so we coerce to str on output.
"""
data = super().to_representation(instance)
data["course_id"] = str(data["course_id"])
return data
2 changes: 1 addition & 1 deletion backend-plugin-sample/src/openedx_plugin_sample/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def unarchive_on_verified_upgrade(

updated = CourseArchiveStatus.objects.filter(
user_id=enrollment.user.id,
course_id=enrollment.course.course_key,
course_run__course_key=enrollment.course.course_key,
is_archived=True,
).update(is_archived=False, archive_date=None)

Expand Down
55 changes: 41 additions & 14 deletions backend-plugin-sample/src/openedx_plugin_sample/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging

from django.utils import timezone
from django_filters import rest_framework as django_filters
from django_filters.rest_framework import DjangoFilterBackend
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
Expand Down Expand Up @@ -64,6 +65,39 @@ class CourseArchiveStatusThrottle(UserRateThrottle):
rate = "60/minute"


class CourseArchiveStatusFilterSet(django_filters.FilterSet):
"""
FilterSet for CourseArchiveStatus.

The model stores a FK to CourseRun, but the public API filters and orders
by the course_key string (never by the internal CourseRun PK).
"""

# Map ?course_id=course-v1:... onto the FK's course_key column.
course_id = django_filters.CharFilter(field_name="course_run__course_key")

# Expose ?ordering=course_id (and other fields) without leaking the
# double-underscore FK lookup path.
ordering = django_filters.OrderingFilter(
fields=(
("course_run__course_key", "course_id"),
("user", "user"),
("is_archived", "is_archived"),
("archive_date", "archive_date"),
("created_at", "created_at"),
("updated_at", "updated_at"),
)
)

class Meta:
"""
FilterSet Meta options for CourseArchiveStatus.
"""

model = CourseArchiveStatus
fields = ["course_id", "user", "is_archived"]


class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
"""
API viewset for CourseArchiveStatus.
Expand All @@ -81,15 +115,7 @@ class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
CourseArchiveStatusThrottle,
]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["course_id", "user", "is_archived"]
ordering_fields = [
"course_id",
"user",
"is_archived",
"archive_date",
"created_at",
"updated_at",
]
filterset_class = CourseArchiveStatusFilterSet
ordering = ["-updated_at"]

def get_queryset(self):
Expand All @@ -104,8 +130,9 @@ def get_queryset(self):
# Validate query parameters to prevent injection
self._validate_query_params()

# Always use select_related to avoid N+1 queries
base_queryset = CourseArchiveStatus.objects.select_related("user")
# Always use select_related to avoid N+1 queries when accessing
# related user and course_run (for course_key) fields.
base_queryset = CourseArchiveStatus.objects.select_related("user", "course_run")

if user.is_staff or user.is_superuser:
return base_queryset
Expand Down Expand Up @@ -172,7 +199,7 @@ def perform_create(self, serializer):
# Log at debug level for normal operation
logger.debug(
"CourseArchiveStatus created: course_id=%s, user=%s, is_archived=%s",
instance.course_id,
instance.course_run.course_key,
instance.user.username,
instance.is_archived,
)
Expand Down Expand Up @@ -218,7 +245,7 @@ def perform_update(self, serializer):
# Log at debug level
logger.debug(
"CourseArchiveStatus updated: course_id=%s, user=%s, is_archived=%s",
updated_instance.course_id,
updated_instance.course_run.course_key,
updated_instance.user.username,
updated_instance.is_archived,
)
Expand All @@ -232,7 +259,7 @@ def perform_destroy(self, instance):
# Log at debug level before deletion
logger.debug(
"CourseArchiveStatus deleted: course_id=%s, user=%s, by=%s",
instance.course_id,
instance.course_run.course_key,
instance.user.username,
self.request.user.username,
)
Expand Down
2 changes: 2 additions & 0 deletions backend-plugin-sample/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def root(*args):
"django_filters",
"edx_django_utils.plugins",
"django_extensions",
"organizations",
"openedx_catalog",
]

# Dynamically add plugin apps - only using the LMS context for simplicity
Expand Down
Loading
Loading