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 ( +
+ + {ratingCount > 0 && ( + {averageStars.toFixed(1)} + )} + + ({ratingCount} {ratingCount === 1 ? "rating" : "ratings"}) + +
+ ); +}; + +export default CourseCardRating; diff --git a/frontend-plugin-sample/src/RateThisContent.jsx b/frontend-plugin-sample/src/RateThisContent.jsx new file mode 100644 index 0000000..bad2b7f --- /dev/null +++ b/frontend-plugin-sample/src/RateThisContent.jsx @@ -0,0 +1,104 @@ +// @@TODO: add tests. +import React, { useEffect, useState } from "react"; +import { getConfig } from "@edx/frontend-platform"; +import { getAuthenticatedHttpClient } from "@edx/frontend-platform/auth"; +import { Icon, IconButton } from "@openedx/paragon"; +import { Star, StarOutline } from "@openedx/paragon/icons"; + +// @@TODO: Verify the actual prop name passed by +// org.openedx.frontend.learning.sequence_container.v1. It might be `unitId`, +// `usageKey`, or nested inside a `unit` object. For this POC we accept any of +// the obvious aliases. +const extractUsageKey = (props) => + props.usageKey || + props.unitId || + props.unit?.id || + props.unit?.usageKey || + null; + +const apiUrl = () => + `${getConfig().LMS_BASE_URL}/sample-plugin/api/v1/unit-rating/`; + +const RateThisContent = (props) => { + const usageKey = extractUsageKey(props); + const [stars, setStars] = useState(0); // 0 = not yet rated + const [existingId, setExistingId] = useState(null); + const [saving, setSaving] = useState(false); + + // Fetch the caller's existing rating for this unit (if any) on mount. + useEffect(() => { + if (!usageKey) return; + let cancelled = false; + (async () => { + try { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(apiUrl(), { + params: { usage_key: usageKey }, + }); + if (cancelled) return; + const existing = (data?.results || [])[0]; + if (existing) { + setStars(existing.stars); + setExistingId(existing.id); + } + } catch (e) { + // @@TODO: surface to user via toast. POC: console only. + console.error("RateThisContent: failed to fetch existing rating", e); + } + })(); + return () => { + cancelled = true; + }; + }, [usageKey]); + + if (!usageKey) { + // No usage key in slot props -- nothing to rate. + // @@TODO: remove once we've confirmed the slot prop shape. + return null; + } + + const handleClick = async (value) => { + const previousStars = stars; + const previousId = existingId; + // Optimistic update. + setStars(value); + setSaving(true); + try { + const client = getAuthenticatedHttpClient(); + if (previousId) { + await client.patch(`${apiUrl()}${previousId}/`, { stars: value }); + } else { + const { data } = await client.post(apiUrl(), { + usage_key: usageKey, + stars: value, + }); + setExistingId(data.id); + } + } catch (e) { + console.error("RateThisContent: failed to save rating", e); + // Revert on failure. + setStars(previousStars); + } finally { + setSaving(false); + } + }; + + return ( +
+ Rate this content: + {[1, 2, 3, 4, 5].map((value) => ( + handleClick(value)} + disabled={saving} + variant="primary" + /> + ))} +
+ ); +}; + +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}

)} +