diff --git a/backend-plugin-sample/src/openedx_plugin_sample/admin.py b/backend-plugin-sample/src/openedx_plugin_sample/admin.py
index ecf3fc7..7fda61c 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/admin.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/admin.py
@@ -13,7 +13,11 @@
from django.contrib import admin
-from openedx_plugin_sample.models import CourseArchiveStatus
+from openedx_plugin_sample.models import (
+ CourseArchiveStatus,
+ CourseAverageRating,
+ UnitRating,
+)
@admin.register(CourseArchiveStatus)
@@ -53,3 +57,48 @@ def course_key(self, obj: CourseArchiveStatus) -> str:
operators recognize.
"""
return str(obj.course_run.course_key)
+
+
+@admin.register(UnitRating)
+class UnitRatingAdmin(admin.ModelAdmin):
+ """Admin for individual user-by-unit ratings."""
+
+ list_display = ("usage_key", "user", "stars", "course_key", "updated_at")
+ list_filter = ("stars",)
+ search_fields = (
+ "usage_key",
+ "course_run__course_key",
+ "user__username",
+ "user__email",
+ )
+ 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: UnitRating) -> str:
+ return str(obj.course_run.course_key)
+
+
+@admin.register(CourseAverageRating)
+class CourseAverageRatingAdmin(admin.ModelAdmin):
+ """Admin for the cached per-course rating aggregate."""
+
+ list_display = (
+ "course_key",
+ "average_stars",
+ "rating_count",
+ "sum_stars",
+ "updated_at",
+ )
+ search_fields = ("course_run__course_key",)
+ raw_id_fields = ("course_run",)
+ # @@TODO: Right now sum/count/average are editable, which is useful for POC
+ # debugging but in production they should be readonly so operators don't
+ # accidentally desync them from the underlying UnitRating rows.
+ readonly_fields = ("updated_at",)
+ ordering = ("-updated_at",)
+
+ @admin.display(description="Course key", ordering="course_run__course_key")
+ def course_key(self, obj: CourseAverageRating) -> str:
+ return str(obj.course_run.course_key)
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/migrations/0002_courseaveragerating_unitrating.py b/backend-plugin-sample/src/openedx_plugin_sample/migrations/0002_courseaveragerating_unitrating.py
new file mode 100644
index 0000000..44e0857
--- /dev/null
+++ b/backend-plugin-sample/src/openedx_plugin_sample/migrations/0002_courseaveragerating_unitrating.py
@@ -0,0 +1,46 @@
+# Generated by Django 5.2.13 on 2026-05-14 19:28
+
+import django.core.validators
+import django.db.models.deletion
+import opaque_keys.edx.django.models
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('openedx_catalog', '0001_initial'),
+ ('openedx_plugin_sample', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CourseAverageRating',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('sum_stars', models.PositiveIntegerField(default=0)),
+ ('rating_count', models.PositiveIntegerField(default=0)),
+ ('average_stars', models.FloatField(blank=True, help_text='Null when rating_count == 0.', null=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('course_run', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='average_rating', to='openedx_catalog.courserun')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='UnitRating',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('usage_key', opaque_keys.edx.django.models.UsageKeyField(db_index=True, help_text="e.g. 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@abc123'", max_length=255)),
+ ('stars', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('course_run', models.ForeignKey(help_text='Denormalized from usage_key so we can aggregate per course cheaply.', on_delete=django.db.models.deletion.CASCADE, related_name='unit_ratings', to='openedx_catalog.courserun')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_ratings', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-updated_at'],
+ 'constraints': [models.UniqueConstraint(fields=('user', 'usage_key'), name='unique_user_unit_rating')],
+ },
+ ),
+ ]
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/models.py b/backend-plugin-sample/src/openedx_plugin_sample/models.py
index 079e4b4..cf3c7ab 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/models.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/models.py
@@ -1,9 +1,20 @@
"""
Database models for openedx_plugin_sample.
+
+Two independent features live side-by-side in this app:
+
+* ``CourseArchiveStatus`` -- per-learner "this course is archived" flag,
+ consumed by the Learner Dashboard course-card listing.
+* ``UnitRating`` + ``CourseAverageRating`` -- learners optionally rate units
+ 1-5 stars; we keep a cached per-course average that is updated incrementally
+ on each write and fully recomputed when a course is republished.
"""
from django.contrib.auth import get_user_model
-from django.db import models
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models, transaction
+from django.db.models import Count, Sum
+from opaque_keys.edx.django.models import UsageKeyField
from openedx_catalog.models import CourseRun
@@ -68,3 +79,135 @@ class Meta:
fields=["course_run", "user"], name="unique_user_course_archive_status"
)
]
+
+
+class UnitRating(models.Model):
+ """
+ A single learner's 1-5 star rating of one unit (vertical block).
+
+ .. no_pii: Stores no PII directly, only FKs to user and course_run.
+ """
+
+ user = models.ForeignKey(
+ get_user_model(),
+ on_delete=models.CASCADE,
+ related_name="unit_ratings",
+ )
+ course_run = models.ForeignKey(
+ CourseRun,
+ on_delete=models.CASCADE,
+ related_name="unit_ratings",
+ help_text="Denormalized from usage_key so we can aggregate per course cheaply.",
+ )
+ usage_key = UsageKeyField(
+ max_length=255,
+ db_index=True,
+ help_text="e.g. 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@abc123'",
+ )
+ stars = models.PositiveSmallIntegerField(
+ validators=[MinValueValidator(1), MaxValueValidator(5)],
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["user", "usage_key"],
+ name="unique_user_unit_rating",
+ ),
+ ]
+ ordering = ["-updated_at"]
+
+ def __str__(self):
+ return f"{self.user.username} rated {self.usage_key}: {self.stars}*"
+
+
+class CourseAverageRating(models.Model):
+ """
+ Cached aggregate of all UnitRatings for a CourseRun.
+
+ Kept in sync incrementally by ``apply_rating_delta`` (called from the API on
+ create/update/destroy) and fully recomputed by ``recompute_from_scratch``
+ on course publish.
+
+ .. no_pii: Aggregate only, no per-user data.
+ """
+
+ course_run = models.OneToOneField(
+ CourseRun,
+ on_delete=models.CASCADE,
+ related_name="average_rating",
+ )
+ # Stored sum + count enables O(1) incremental updates without re-aggregating.
+ sum_stars = models.PositiveIntegerField(default=0)
+ rating_count = models.PositiveIntegerField(default=0)
+ average_stars = models.FloatField(
+ null=True,
+ blank=True,
+ help_text="Null when rating_count == 0.",
+ )
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ if self.rating_count == 0:
+ return f"{self.course_run.course_key}: no ratings yet"
+ return (
+ f"{self.course_run.course_key}: {self.average_stars:.2f}* "
+ f"({self.rating_count} ratings)"
+ )
+
+ def _refresh_average(self):
+ """Recompute ``average_stars`` from current ``sum_stars`` / ``rating_count``."""
+ if self.rating_count == 0:
+ self.average_stars = None
+ else:
+ self.average_stars = self.sum_stars / self.rating_count
+
+ def recompute_from_scratch(self, allowed_usage_keys=None):
+ """
+ Rebuild sum/count/average by scanning UnitRating rows for this course_run.
+
+ Args:
+ allowed_usage_keys: Optional iterable of usage_keys to restrict the
+ aggregation to. Intended for the publish handler so that
+ ratings for units that have since been removed are excluded.
+ If None, all UnitRating rows for the course_run are included.
+ """
+ qs = UnitRating.objects.filter(course_run=self.course_run)
+ if allowed_usage_keys is not None:
+ qs = qs.filter(usage_key__in=list(allowed_usage_keys))
+ agg = qs.aggregate(total=Sum("stars"), n=Count("id"))
+ self.sum_stars = agg["total"] or 0
+ self.rating_count = agg["n"] or 0
+ self._refresh_average()
+ self.save()
+
+
+def apply_rating_delta(course_run, *, old_stars, new_stars):
+ """
+ Update the cached CourseAverageRating to reflect a single rating change.
+
+ Pass ``old_stars=None`` for a brand-new rating, ``new_stars=None`` for a
+ deletion, or both non-None for an update.
+
+ Wrapped in ``transaction.atomic`` + ``select_for_update`` so concurrent
+ rating writes against the same course don't race on the cached aggregate.
+ """
+ if old_stars is None and new_stars is None:
+ return
+
+ with transaction.atomic():
+ avg, _ = CourseAverageRating.objects.select_for_update().get_or_create(
+ course_run=course_run,
+ )
+ if old_stars is None:
+ avg.sum_stars += new_stars
+ avg.rating_count += 1
+ elif new_stars is None:
+ avg.sum_stars = max(0, avg.sum_stars - old_stars)
+ avg.rating_count = max(0, avg.rating_count - 1)
+ else:
+ avg.sum_stars = max(0, avg.sum_stars - old_stars) + new_stars
+ avg._refresh_average()
+ avg.save()
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/pipeline.py b/backend-plugin-sample/src/openedx_plugin_sample/pipeline.py
index 9068540..511d31c 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/pipeline.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/pipeline.py
@@ -42,7 +42,7 @@
import crum
from openedx_filters.filters import PipelineStep
-from .models import CourseArchiveStatus
+from .models import CourseArchiveStatus, CourseAverageRating
logger = logging.getLogger(__name__)
@@ -89,3 +89,50 @@ def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=argumen
"isArchivedByLearner": is_archived_by_learner,
},
}
+
+
+class AddAverageRatingToLearnerHomeCourseRun(PipelineStep):
+ """
+ Decorate each courseRun in the Learner Home /init response with the cached
+ average rating, so the Archive course-card display can render stars without
+ a second API call.
+
+ Filter name: ``org.openedx.learning.home.courserun.api.rendered.started.v1``
+ """
+
+ def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=arguments-differ
+ """
+ Args:
+ serialized_courserun (dict): One courseRun from the /init serializer.
+ Reads ``courseId``; passes all other fields through unchanged.
+
+ Returns:
+ dict: ``{"serialized_courserun": }`` with two new keys
+ added:
+ - ``averageStars`` (float or None) -- None when no ratings exist
+ - ``ratingCount`` (int) -- 0 when no ratings exist
+ """
+ course_id = serialized_courserun.get("courseId")
+ if not course_id:
+ return {"serialized_courserun": serialized_courserun}
+
+ # @@TODO: looking up one row per courseRun is fine for a dashboard with a
+ # handful of courses but will N+1 on long course lists. A prefetch in
+ # the upstream serializer (or a bulk lookup here) would be the prod fix.
+ try:
+ avg = CourseAverageRating.objects.get(
+ course_run__course_key=course_id,
+ )
+ average_stars = avg.average_stars
+ rating_count = avg.rating_count
+ except CourseAverageRating.DoesNotExist:
+ average_stars = None
+ rating_count = 0
+
+ return {
+ "serialized_courserun": {
+ **serialized_courserun,
+ "averageStars": average_stars,
+ "ratingCount": rating_count,
+ },
+ }
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/serializers.py b/backend-plugin-sample/src/openedx_plugin_sample/serializers.py
index d7abde9..df42e15 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/serializers.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/serializers.py
@@ -3,10 +3,17 @@
"""
from django.contrib.auth import get_user_model
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import UsageKey
from openedx_catalog.models import CourseRun
from rest_framework import serializers
-from openedx_plugin_sample.models import CourseArchiveStatus
+from openedx_plugin_sample.models import (
+ CourseArchiveStatus,
+ CourseAverageRating,
+ UnitRating,
+ apply_rating_delta,
+)
User = get_user_model()
@@ -59,3 +66,93 @@ def to_representation(self, instance):
data = super().to_representation(instance)
data["course_id"] = str(data["course_id"])
return data
+
+
+class UnitRatingSerializer(serializers.ModelSerializer):
+ """
+ Serializer for a single learner's rating of a unit.
+
+ Clients send/receive ``usage_key`` as a string; the FK to CourseRun is
+ derived server-side from the usage_key's course_key.
+ """
+
+ user = serializers.PrimaryKeyRelatedField(
+ queryset=User.objects.all(),
+ default=serializers.CurrentUserDefault(),
+ required=False,
+ )
+ usage_key = serializers.CharField(max_length=255)
+
+ class Meta:
+ model = UnitRating
+ fields = [
+ "id",
+ "usage_key",
+ "stars",
+ "user",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+ def _resolve_course_run(self, usage_key_str):
+ """Parse the usage_key and return the owning CourseRun."""
+ try:
+ usage_key = UsageKey.from_string(usage_key_str)
+ except InvalidKeyError as exc:
+ raise serializers.ValidationError(
+ {"usage_key": f"Invalid usage key: {usage_key_str}"}
+ ) from exc
+ # @@TODO: in prod, ``CourseRun.objects.get(...)`` and 400 if absent. The
+ # POC tolerates missing rows so a fresh dev DB doesn't need to be
+ # pre-seeded with every course's CourseRun.
+ course_run, _ = CourseRun.objects.get_or_create(
+ course_key=str(usage_key.course_key),
+ )
+ return course_run
+
+ def create(self, validated_data):
+ course_run = self._resolve_course_run(validated_data["usage_key"])
+ instance = UnitRating.objects.create(course_run=course_run, **validated_data)
+ apply_rating_delta(course_run, old_stars=None, new_stars=instance.stars)
+ return instance
+
+ def update(self, instance, validated_data):
+ old_stars = instance.stars
+ # @@TODO: usage_key updates would imply the rating moved to a different
+ # unit (and possibly course). POC: ignore changes to usage_key and only
+ # respect ``stars`` updates.
+ new_stars = validated_data.get("stars", old_stars)
+ instance.stars = new_stars
+ instance.save(update_fields=["stars", "updated_at"])
+ if new_stars != old_stars:
+ apply_rating_delta(
+ instance.course_run, old_stars=old_stars, new_stars=new_stars,
+ )
+ return instance
+
+
+class CourseAverageRatingSerializer(serializers.ModelSerializer):
+ """Read-only serializer for the cached per-course rating aggregate."""
+
+ course_id = serializers.SlugRelatedField(
+ source="course_run",
+ slug_field="course_key",
+ read_only=True,
+ )
+
+ class Meta:
+ model = CourseAverageRating
+ fields = [
+ "course_id",
+ "average_stars",
+ "rating_count",
+ "sum_stars",
+ "updated_at",
+ ]
+ read_only_fields = fields
+
+ def to_representation(self, instance):
+ data = super().to_representation(instance)
+ data["course_id"] = str(data["course_id"])
+ return data
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/settings/common.py b/backend-plugin-sample/src/openedx_plugin_sample/settings/common.py
index 627ec3c..76e4087 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/settings/common.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/settings/common.py
@@ -96,34 +96,39 @@ def _configure_openedx_filters(settings):
# Get existing filter configuration (may be from other plugins or platform)
filters_config = getattr(settings, 'OPEN_EDX_FILTERS_CONFIG', {})
- # Filter we want to register
+ # Both of our filter steps target the same Learner Home /init filter:
+ # one injects per-learner archive state (Archive feature), the other
+ # injects the per-course rating aggregate (Rating feature). They share
+ # the same hook, so register them together.
filter_name = "org.openedx.learning.home.courserun.api.rendered.started.v1"
- our_pipeline_step = "openedx_plugin_sample.pipeline.AddArchiveStatusToLearnerHomeCourseRun"
+ our_pipeline_steps = [
+ "openedx_plugin_sample.pipeline.AddArchiveStatusToLearnerHomeCourseRun",
+ "openedx_plugin_sample.pipeline.AddAverageRatingToLearnerHomeCourseRun",
+ ]
# Check if this filter already has configuration
if filter_name in filters_config:
- logger.debug(f"Filter {filter_name} already configured, adding our pipeline step")
+ logger.debug(f"Filter {filter_name} already configured, adding our pipeline steps")
# Get existing pipeline steps
existing_pipeline = filters_config[filter_name].get("pipeline", [])
- # Check if our pipeline step is already registered
- if our_pipeline_step in existing_pipeline:
- logger.info(
- f"Pipeline step {our_pipeline_step} already registered for filter {filter_name}. "
- "This may indicate the plugin is being loaded multiple times or another plugin "
- "has registered the same pipeline step."
- )
- else:
- # Add our pipeline step to existing configuration
- existing_pipeline.append(our_pipeline_step)
- filters_config[filter_name]["pipeline"] = existing_pipeline
- logger.debug(f"Added {our_pipeline_step} to existing filter configuration")
+ for step in our_pipeline_steps:
+ if step in existing_pipeline:
+ logger.info(
+ f"Pipeline step {step} already registered for filter {filter_name}. "
+ "This may indicate the plugin is being loaded multiple times or another plugin "
+ "has registered the same pipeline step."
+ )
+ else:
+ existing_pipeline.append(step)
+ logger.debug(f"Added {step} to existing filter configuration")
+ filters_config[filter_name]["pipeline"] = existing_pipeline
else:
# Create new filter configuration
logger.debug(f"Creating new filter configuration for {filter_name}")
filters_config[filter_name] = {
- "pipeline": [our_pipeline_step],
+ "pipeline": list(our_pipeline_steps),
"fail_silently": False,
}
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/signals.py b/backend-plugin-sample/src/openedx_plugin_sample/signals.py
index d8ae87e..e304742 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/signals.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/signals.py
@@ -21,10 +21,13 @@
import logging
from django.dispatch import receiver
+from openedx_catalog.models import CourseRun
+from openedx_events.content_authoring.data import XBlockData
+from openedx_events.content_authoring.signals import XBLOCK_PUBLISHED
from openedx_events.learning.data import CourseEnrollmentData
from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED
-from .models import CourseArchiveStatus
+from .models import CourseArchiveStatus, CourseAverageRating
logger = logging.getLogger(__name__)
@@ -64,3 +67,43 @@ def unarchive_on_verified_upgrade(
enrollment.course.course_key,
enrollment.user.id,
)
+
+
+@receiver(XBLOCK_PUBLISHED)
+def recompute_course_average_on_publish(
+ signal, sender, xblock_info: XBlockData, **kwargs,
+): # pylint: disable=unused-argument
+ """
+ Fully recompute the cached CourseAverageRating when a full course is
+ published.
+
+ Open edX does not (currently) ship a ``COURSE_PUBLISHED`` event. The closest
+ analog is ``XBLOCK_PUBLISHED``, which fires once per publish action in
+ Studio with the *highest* affected xblock's usage_key. We only care about
+ full-course publishes, since smaller publish actions (a single unit, a
+ section) don't change the set of units the course contains.
+
+ @@TODO: Pass the set of usage_keys still present in the published outline
+ to ``recompute_from_scratch`` so ratings for removed units are
+ excluded. The current POC simply averages over every UnitRating row
+ for the course, which is wrong if a previously-rated unit has since
+ been removed (its rating will still count).
+ """
+ if xblock_info.block_type != "course":
+ return
+
+ course_key = xblock_info.usage_key.course_key
+ try:
+ course_run = CourseRun.objects.get(course_key=str(course_key))
+ except CourseRun.DoesNotExist:
+ # No ratings could exist if we don't even know about the course yet.
+ return
+
+ avg, _ = CourseAverageRating.objects.get_or_create(course_run=course_run)
+ avg.recompute_from_scratch()
+ logger.info(
+ "Recomputed CourseAverageRating for %s: %s* over %d ratings",
+ course_key,
+ avg.average_stars,
+ avg.rating_count,
+ )
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/urls.py b/backend-plugin-sample/src/openedx_plugin_sample/urls.py
index 0d4df6a..5dbfd41 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/urls.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/urls.py
@@ -5,7 +5,11 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
-from openedx_plugin_sample.views import CourseArchiveStatusViewSet
+from openedx_plugin_sample.views import (
+ CourseArchiveStatusViewSet,
+ CourseAverageRatingViewSet,
+ UnitRatingViewSet,
+)
# Create a router and register our viewsets with it
router = DefaultRouter()
@@ -14,6 +18,14 @@
CourseArchiveStatusViewSet,
basename="course-archive-status",
)
+router.register(r"unit-rating", UnitRatingViewSet, basename="unit-rating")
+# @@TODO: CourseAverageRating detail-by-course_key needs a custom URL or query
+# filter; for now only the list endpoint is meaningfully usable.
+router.register(
+ r"course-average-rating",
+ CourseAverageRatingViewSet,
+ basename="course-average-rating",
+)
# The API URLs are now determined automatically by the router
urlpatterns = [
diff --git a/backend-plugin-sample/src/openedx_plugin_sample/views.py b/backend-plugin-sample/src/openedx_plugin_sample/views.py
index 3e59c17..62d6bb7 100644
--- a/backend-plugin-sample/src/openedx_plugin_sample/views.py
+++ b/backend-plugin-sample/src/openedx_plugin_sample/views.py
@@ -9,13 +9,22 @@
from django_filters.rest_framework import DjangoFilterBackend
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
-from rest_framework import filters, permissions, viewsets
+from rest_framework import filters, mixins, permissions, viewsets
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.pagination import PageNumberPagination
from rest_framework.throttling import UserRateThrottle
-from openedx_plugin_sample.models import CourseArchiveStatus
-from openedx_plugin_sample.serializers import CourseArchiveStatusSerializer
+from openedx_plugin_sample.models import (
+ CourseArchiveStatus,
+ CourseAverageRating,
+ UnitRating,
+ apply_rating_delta,
+)
+from openedx_plugin_sample.serializers import (
+ CourseArchiveStatusSerializer,
+ CourseAverageRatingSerializer,
+ UnitRatingSerializer,
+)
logger = logging.getLogger(__name__)
@@ -266,3 +275,125 @@ def perform_destroy(self, instance):
# Delete the instance
return super().perform_destroy(instance)
+
+
+class UnitRatingPagination(PageNumberPagination):
+ """Pagination for UnitRating list/CourseAverageRating list."""
+
+ page_size = 20
+ page_size_query_param = "page_size"
+ max_page_size = 100
+
+
+class UnitRatingThrottle(UserRateThrottle):
+ """Throttle for the UnitRating API."""
+
+ rate = "60/minute"
+
+
+class UnitRatingFilterSet(django_filters.FilterSet):
+ """
+ Public filters map onto the user-friendly fields rather than FK PKs.
+ """
+
+ # ?course_id=course-v1:... -> course_run.course_key
+ course_id = django_filters.CharFilter(field_name="course_run__course_key")
+
+ ordering = django_filters.OrderingFilter(
+ fields=(
+ ("usage_key", "usage_key"),
+ ("stars", "stars"),
+ ("created_at", "created_at"),
+ ("updated_at", "updated_at"),
+ )
+ )
+
+ class Meta:
+ model = UnitRating
+ fields = ["usage_key", "course_id", "user", "stars"]
+
+
+class UnitRatingViewSet(viewsets.ModelViewSet):
+ """
+ Per-unit rating CRUD.
+
+ GET /unit-rating/?usage_key= -> the caller's rating for that unit
+ POST /unit-rating/ -> create a new rating
+ PATCH /unit-rating// -> update an existing rating
+ DELETE /unit-rating// -> remove a rating
+
+ Regular users only see their own rows; staff/superusers see all.
+ """
+
+ serializer_class = UnitRatingSerializer
+ permission_classes = [IsOwnerOrStaffSuperuser]
+ pagination_class = UnitRatingPagination
+ throttle_classes = [UnitRatingThrottle]
+ filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
+ filterset_class = UnitRatingFilterSet
+ ordering = ["-updated_at"]
+
+ def get_queryset(self):
+ user = self.request.user
+ qs = UnitRating.objects.select_related("user", "course_run")
+ if user.is_staff or user.is_superuser:
+ return qs
+ return qs.filter(user=user)
+
+ def perform_create(self, serializer):
+ # Block users from creating rows on behalf of other users.
+ if "user" in self.request.data:
+ requested_user_id = self.request.data["user"]
+ if requested_user_id != self.request.user.id and not (
+ self.request.user.is_staff or self.request.user.is_superuser
+ ):
+ raise PermissionDenied(
+ "You do not have permission to create ratings for other users."
+ )
+ # The serializer's create() handles the aggregate update.
+ serializer.save()
+
+ def perform_update(self, serializer):
+ if "user" in self.request.data:
+ requested_user_id = self.request.data["user"]
+ if requested_user_id != self.request.user.id and not (
+ self.request.user.is_staff or self.request.user.is_superuser
+ ):
+ raise PermissionDenied(
+ "You do not have permission to update ratings for other users."
+ )
+ serializer.save()
+
+ def perform_destroy(self, instance):
+ # Decrement the cached aggregate before deleting the row.
+ apply_rating_delta(
+ instance.course_run,
+ old_stars=instance.stars,
+ new_stars=None,
+ )
+ super().perform_destroy(instance)
+
+
+class CourseAverageRatingViewSet(
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ viewsets.GenericViewSet,
+):
+ """
+ Read-only access to the cached per-course rating aggregate.
+
+ Mostly useful for debugging / admin. The frontend reads the same data off
+ the Learner Home /init response via the filter pipeline rather than calling
+ this endpoint directly.
+ """
+
+ serializer_class = CourseAverageRatingSerializer
+ permission_classes = [permissions.IsAuthenticated]
+ pagination_class = UnitRatingPagination
+ queryset = CourseAverageRating.objects.select_related("course_run").all()
+ # Look up by course_key string rather than internal PK.
+ lookup_field = "course_run__course_key"
+ lookup_url_kwarg = "course_id"
+ # @@TODO: courseId strings contain ':' and '+' which trip up default DRF
+ # URL routing. Either set a custom regex on the route or expose an
+ # ``?course_id=`` filter instead.
diff --git a/frontend-plugin-sample/README.md b/frontend-plugin-sample/README.md
index f44b193..f2554bb 100644
--- a/frontend-plugin-sample/README.md
+++ b/frontend-plugin-sample/README.md
@@ -1,6 +1,9 @@
# Frontend Plugin Implementation Guide
-This directory contains a React component that demonstrates how to customize Open edX micro-frontends (MFEs) using the Frontend Plugin Framework. The plugin replaces the default course list in the learner dashboard with a custom implementation that includes course archiving functionality.
+This directory contains React components that demonstrate how to customize Open edX micro-frontends (MFEs) using the Frontend Plugin Framework. Two features are wired up side by side:
+
+- **Archive** — `CourseList` replaces the default course list on the learner-dashboard with one that adds per-learner archive/unarchive controls.
+- **Rating** — `RateThisContent` slots a 1-5 star widget below each unit in `frontend-app-learning`; `CourseCardRating` (used internally by `CourseList`) shows the resulting per-course average on each card.
## Table of Contents
@@ -16,13 +19,13 @@ This directory contains a React component that demonstrates how to customize Ope
## Overview
-This frontend plugin demonstrates **Open edX MFE customization** using the Frontend Plugin Framework to replace the course list component in the learner dashboard.
+This frontend plugin demonstrates **Open edX MFE customization** using the Frontend Plugin Framework against slots in two different MFEs.
**What this plugin provides:**
-- **Custom CourseList Component**: Enhanced course display with archive functionality
+- **Custom CourseList Component**: Enhanced course display with archive functionality and inline per-course rating
+- **RateThisContent Component**: Per-unit 1-5 star rating widget for the learning MFE
- **Backend API Integration**: Connects to the sample backend plugin APIs
-- **Slot Replacement Pattern**: Shows how to replace existing MFE components
-- **State Management**: React patterns for plugin development
+- **Slot Replacement and Insert Patterns**: Demonstrates both fully replacing a slot's default contents and inserting a new widget into a host slot
- **Authentication Integration**: Uses Open edX authentication system
**Official Documentation:**
@@ -92,11 +95,17 @@ const courses = courseListData.visibleList;
#### 2. Backend Data via the Filter Pipeline
-Rather than firing an extra GET to `course-archive-status/` on every dashboard
-load, the initial archive state is read directly off the slot props. The backend
-plugin uses an Open edX filter (see [`pipeline.py`](../backend-plugin-sample/src/openedx_plugin_sample/pipeline.py))
-to inject `isArchivedByLearner` into each courseRun in the Learner Home `/init`
-API response, so it arrives alongside the rest of the course data:
+Rather than firing extra GETs for each card on every dashboard load, the
+initial archive state *and* the per-course average rating are read directly off
+the slot props. The backend plugin runs two filter pipeline steps (see
+[`pipeline.py`](../backend-plugin-sample/src/openedx_plugin_sample/pipeline.py))
+that inject extra keys onto each courseRun in the Learner Home `/init` response:
+
+- `isArchivedByLearner` (bool) — does the requesting user have this course archived?
+- `averageStars` (float or null) — cached per-course rating average
+- `ratingCount` (int) — number of unit ratings backing that average
+
+The component reads them straight off the slot props:
```jsx
const [archivedCourses, setArchivedCourses] = useState(() => {
@@ -108,15 +117,18 @@ const [archivedCourses, setArchivedCourses] = useState(() => {
});
return initial;
});
+
+// And inside each card:
+
```
-**Why this pattern**: One fewer round-trip per dashboard load, and the archive
-state is consistent with the rest of the course data from the same response.
-The REST API is still used for writes (archive/unarchive) — see the toggle
-handler below.
+**Why this pattern**: One fewer round-trip per dashboard load, and the injected
+state stays consistent with the rest of the course data from the same response.
+The REST API is still used for writes (archive/unarchive, submit rating) — see
+the toggle handler below.
**Key Patterns:**
-- **Filter-injected data**: Read `courseRun.isArchivedByLearner` straight from slot props
+- **Filter-injected data**: Read `courseRun.isArchivedByLearner` / `averageStars` / `ratingCount` straight from slot props
- **Authentication** (for writes): `getAuthenticatedHttpClient()` handles Open edX auth
- **Configuration**: `getConfig().LMS_BASE_URL` gets platform URLs
@@ -189,43 +201,66 @@ const handleArchiveToggle = async (courseId, isCurrentlyArchived) => {
## Slot Integration Patterns
-### CourseListSlot Integration
+This plugin targets two slots, in two different MFEs:
-**Target Slot**: `course_list_slot` in learner dashboard
+| Slot ID | MFE | Component | Operation |
+|---------|-----|-----------|-----------|
+| `org.openedx.frontend.learner_dashboard.course_list.v1` | `frontend-app-learner-dashboard` | `CourseList` | `Hide` default widget + `Insert` ours |
+| `org.openedx.frontend.learning.sequence_container.v1` | `frontend-app-learning` | `RateThisContent` | `Insert` ours alongside default |
-**Configuration Pattern** (for local development in `env.config.jsx`):
+### CourseList slot (Hide + Insert)
+
+The default `default_contents` widget is hidden so it doesn't render *next to*
+our replacement. We then insert our `CourseList` widget into the same slot:
```javascript
-import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
-import { CourseList } from '@openedx/plugin-sample';
+'org.openedx.frontend.learner_dashboard.course_list.v1': {
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Hide,
+ widgetId: 'default_contents',
+ },
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'openedx_plugin_sample_course_list',
+ type: DIRECT_PLUGIN,
+ priority: 50,
+ RenderWidget: CourseList,
+ },
+ },
+ ],
+},
+```
-const config = {
- pluginSlots: {
- course_list_slot: {
- keepDefault: false, // Hide original component
- plugins: [
- {
- op: PLUGIN_OPERATIONS.Insert,
- widget: {
- id: 'custom_course_list',
- type: DIRECT_PLUGIN,
- priority: 60,
- RenderWidget: CourseList // Your custom component
- },
- },
- ],
+### RateThisContent slot (Insert only)
+
+The learning MFE's sequence-container slot has no widget we need to displace,
+so a single `Insert` is enough:
+
+```javascript
+'org.openedx.frontend.learning.sequence_container.v1': {
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'openedx_plugin_sample_rate_this_content',
+ type: DIRECT_PLUGIN,
+ priority: 50,
+ RenderWidget: RateThisContent,
+ },
},
- },
-}
+ ],
+},
```
### Plugin Configuration Options
| Option | Purpose | Values |
|--------|---------|--------|
-| **keepDefault** | Show/hide original component | `true`, `false` |
-| **op** | Plugin operation type | `Insert`, `Modify`, `Replace` |
-| **priority** | Loading order | Higher numbers load later |
+| **op** | Plugin operation type | `Insert`, `Hide`, `Modify`, `Wrap`, `Replace` |
+| **widgetId** | Identifies which existing widget to act on (for `Hide`/`Modify`/`Wrap`/`Replace`) | The host slot's widget ID, often `'default_contents'` |
+| **priority** | Loading order within the slot | Higher numbers render later |
| **type** | Plugin implementation type | `DIRECT_PLUGIN`, `IFRAME_PLUGIN` |
| **RenderWidget** | Your React component | Component reference |
@@ -312,11 +347,20 @@ if (response.data && Array.isArray(response.data)) {
### Local Development Setup
-#### Step 1: Create module.config.js
+The plugin's two slots live in two different MFEs:
+
+- `frontend-app-learner-dashboard` — for the Archive `CourseList` (and the
+ embedded per-course rating display)
+- `frontend-app-learning` — for the per-unit `RateThisContent` widget
-Create `module.config.js` in your MFE root, not committed to the repo.
-This tells the MFE to load/use the `@openedx/sample-plugin` package
-as a source (non-built) distribution .
+You only need to write the `module.config.js` and `env.config.jsx` files once,
+then drop a copy of each into the root of every MFE you want to customize.
+
+#### Step 1: Create `module.config.js` (per MFE)
+
+In the MFE root, create `module.config.js` (do not commit it). This tells the
+MFE's webpack to load `@openedx/plugin-sample` from your local checkout instead
+of from npm, so you can iterate on the plugin without publishing:
```javascript
module.exports = {
@@ -324,72 +368,118 @@ module.exports = {
{
moduleName: '@openedx/plugin-sample',
dir: '/path/to/sample-plugin/frontend-plugin-sample',
- dist: 'src'
+ dist: 'src',
},
],
};
```
-#### Step 2: Create env.config.jsx
+#### Step 2: Create a shared `env.config.jsx` and drop a copy into each MFE
-Create `env.config.jsx` in your MFE root, not committed to the repo.
-This plugs the sample widget into the course list slot.
+`env.config.jsx` is how each MFE resolves its plugin-slot configuration at
+build/runtime. A given MFE only acts on the slots it actually owns and silently
+ignores the rest, so the easiest thing for development is to keep **one
+`env.config.jsx`** that lists every slot this plugin targets and copy the same
+file into each MFE root you're customizing.
+
+```jsx
+// env.config.jsx -- copy this file into the root of every MFE you want to
+// customize (frontend-app-learner-dashboard, frontend-app-learning, ...).
+// Each MFE only acts on the slots it owns and ignores the others.
-```javascript
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
-import { CourseList } from '@openedx/plugin-sample';
+import { CourseList, RateThisContent } from '@openedx/plugin-sample';
const config = {
pluginSlots: {
- course_list_slot: {
- keepDefault: false,
+ // Lives in: frontend-app-learner-dashboard
+ // Effect: hides the default course list and renders our archive-aware one,
+ // which also displays the per-course rating injected by the backend filter.
+ 'org.openedx.frontend.learner_dashboard.course_list.v1': {
plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Hide,
+ widgetId: 'default_contents',
+ },
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
- id: 'custom_course_list',
+ id: 'openedx_plugin_sample_course_list',
type: DIRECT_PLUGIN,
- priority: 60,
- RenderWidget: CourseList
+ priority: 50,
+ RenderWidget: CourseList,
+ },
+ },
+ ],
+ },
+
+ // Lives in: frontend-app-learning
+ // Effect: inserts a "Rate this content" widget below each unit.
+ 'org.openedx.frontend.learning.sequence_container.v1': {
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'openedx_plugin_sample_rate_this_content',
+ type: DIRECT_PLUGIN,
+ priority: 50,
+ RenderWidget: RateThisContent,
},
},
],
},
},
-}
+};
export default config;
```
-**Purpose**: Webpack uses your local plugin code instead of the installed package.
-#### Step 3: Start Development
+Do not commit `env.config.jsx` into the MFE repos — it's your local override.
-Now, from the MFE repository root, install requirements and run the dev server.
+**Notes:**
+- Slot IDs are fully qualified (`org.openedx.frontend...v`).
+ Don't use the short names you sometimes see in older docs — those won't match.
+- Use `PLUGIN_OPERATIONS.Hide` + `Insert` to fully replace a slot's default
+ widget. `keepDefault: false` (an older alternative) works in some slot
+ versions but the Hide/Insert pattern is what the Tutor plugin in this repo
+ emits, so the dev and prod configurations stay consistent.
+- The plugin's npm package is `@openedx/plugin-sample`. Step 1's
+ `module.config.js` is what makes that import resolve to your local source.
+
+#### Step 3: Start the MFE dev server
+
+From the MFE repository root:
```bash
-# Install requirements
+# Install MFE dependencies (just once).
npm ci
-# If running Tutor:
-tutor mounts add . # Instruct tutor-mfe to redict requests to this local MFE devserver
-tutor dev reboot -d mfe
-npm run dev
+# If you're running Tutor, point its MFE container at your local devserver:
+tutor mounts add .
+tutor dev reboot -d mfe
-# If not running Tutor:
-npm start
+# Then run the MFE devserver itself:
+npm run dev # if running under Tutor
+# or
+npm start # if running standalone (no Tutor)
```
+Repeat for the second MFE if you want to develop both features at once.
+
### Development vs Production Configuration
**Local Development**:
-- Uses `env.config.jsx` for slot configuration
-- Uses `module.config.js` for local code loading
-- Hot reload for faster development
+- One shared `env.config.jsx` that lists every slot this plugin targets,
+ copied into each MFE root you want to customize
+- A matching `module.config.js` in each MFE root, pointing at your local checkout
+- Hot reload via `npm run dev` / `npm start`
**Production Deployment**:
-- Configuration via Tutor plugins
-- Plugin installed as npm package
-- Optimized builds and caching
+- Equivalent slot configuration is emitted by the Tutor plugin
+ ([`tutor-contrib-sample/tutorsample/plugin.py`](../tutor-contrib-sample/tutorsample/plugin.py))
+ via `PLUGIN_SLOTS.add_item(...)`
+- The plugin is installed as the published `@openedx/plugin-sample` npm package
+- Optimized production builds; no local-source mounting
### Testing Frontend Plugins
@@ -430,20 +520,43 @@ Test within the actual MFE environment:
**Tutor Plugin Configuration** (see [`../tutor-contrib-sample/README.md`](../tutor-contrib-sample/README.md)):
```python
-# In tutor plugin
-PLUGIN_SLOTS.add_items([
- (
- "learner-dashboard",
- "custom_course_list",
- """
- {
- op: PLUGIN_OPERATIONS.Insert,
- type: DIRECT_PLUGIN,
- priority: 50,
- RenderWidget: CourseList
- }"""
- ),
-])
+from tutormfe.hooks import PLUGIN_SLOTS
+
+# Archive: replace the default course list on the learner dashboard.
+PLUGIN_SLOTS.add_item((
+ "learner-dashboard",
+ "org.openedx.frontend.learner_dashboard.course_list.v1",
+ """
+ {
+ op: PLUGIN_OPERATIONS.Hide,
+ widgetId: 'default_contents',
+ },
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'openedx_plugin_sample_course_list',
+ type: DIRECT_PLUGIN,
+ priority: 50,
+ RenderWidget: CourseList,
+ },
+ }""",
+))
+
+# Rating: add the per-unit rating widget to the learning MFE.
+PLUGIN_SLOTS.add_item((
+ "learning",
+ "org.openedx.frontend.learning.sequence_container.v1",
+ """
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'openedx_plugin_sample_rate_this_content',
+ type: DIRECT_PLUGIN,
+ priority: 50,
+ RenderWidget: RateThisContent,
+ },
+ }""",
+))
```
### Performance Considerations
diff --git a/frontend-plugin-sample/src/CourseCardRating.jsx b/frontend-plugin-sample/src/CourseCardRating.jsx
new file mode 100644
index 0000000..7972b86
--- /dev/null
+++ b/frontend-plugin-sample/src/CourseCardRating.jsx
@@ -0,0 +1,27 @@
+// @@TODO: add tests.
+import React from "react";
+import { Icon } from "@openedx/paragon";
+import { Star } from "@openedx/paragon/icons";
+
+// Reads `averageStars` / `ratingCount` off a courseRun object. Those two fields
+// are injected into each courseRun by the backend plugin's filter pipeline
+// step (AddAverageRatingToLearnerHomeCourseRun), so any consumer that hands us
+// the courseRun from the Learner Home /init response will Just Work.
+const CourseCardRating = ({ courseRun }) => {
+ const averageStars = courseRun?.averageStars ?? null;
+ const ratingCount = courseRun?.ratingCount ?? 0;
+
+ return (
+
+ );
+};
+
+export default RateThisContent;
diff --git a/frontend-plugin-sample/src/index.jsx b/frontend-plugin-sample/src/index.jsx
index 87876c3..92dbad2 100644
--- a/frontend-plugin-sample/src/index.jsx
+++ b/frontend-plugin-sample/src/index.jsx
@@ -1,4 +1,5 @@
-import CourseList from './plugin'
+import CourseList from './plugin';
+import RateThisContent from './RateThisContent';
/* If we want to add more plugins, we would import them above and then add them to the list of exports below. */
-export {CourseList}
+export { CourseList, RateThisContent };
diff --git a/frontend-plugin-sample/src/plugin.jsx b/frontend-plugin-sample/src/plugin.jsx
index b6cd646..2664771 100644
--- a/frontend-plugin-sample/src/plugin.jsx
+++ b/frontend-plugin-sample/src/plugin.jsx
@@ -16,6 +16,8 @@ import {
} from "@openedx/paragon";
import { Archive, Unarchive, MoreVert } from "@openedx/paragon/icons";
+import CourseCardRating from "./CourseCardRating";
+
const CourseList = ({ courseListData }) => {
// Seed the archived-course set from `courseRun.isArchivedByLearner`, which the
// backend plugin's filter pipeline injects into each courseRun in the Learner
@@ -154,6 +156,7 @@ const CourseList = ({ courseListData }) => {
{courseData.course.shortDescription}