Skip to content

Commit cec7604

Browse files
kdmccormickclaude
andcommitted
feat: Add Rating sample (alongside Archive sample)
Backend: - UnitRating (per-user 1-5 star rating of a unit; uses opaque_keys UsageKeyField) and CourseAverageRating (cached per-course aggregate kept in sync incrementally on writes, fully recomputed on full course publish). - DRF endpoints at /api/v1/unit-rating/ and /api/v1/course-average-rating/. - Second filter pipeline step on Learner Home /init injects averageStars and ratingCount onto each courseRun, alongside the existing isArchivedByLearner. - XBLOCK_PUBLISHED handler recomputes only when block_type == "course" so unit/section publishes don't re-aggregate. - Admin registrations for both new models. Frontend: - RateThisContent: per-unit star widget for frontend-app-learning's sequence_container.v1 slot. - CourseCardRating: per-course average display, embedded inside the Archive CourseList card (rather than the platform course-card slot). Renders "(N ratings)" even at 0 so the wiring is visibly working before any ratings exist. Tutor plugin: - Second PLUGIN_SLOTS entry registers RateThisContent in the learning MFE, next to the existing learner-dashboard course_list slot. Frontend README: - env.config.jsx instructions rewritten around a single shared file that covers both slots and gets copied into each MFE root. A handful of @@todos remain in-code for follow-up POC cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ccc9a1d commit cec7604

15 files changed

Lines changed: 968 additions & 122 deletions

File tree

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313

1414
from django.contrib import admin
1515

16-
from openedx_plugin_sample.models import CourseArchiveStatus
16+
from openedx_plugin_sample.models import (
17+
CourseArchiveStatus,
18+
CourseAverageRating,
19+
UnitRating,
20+
)
1721

1822

1923
@admin.register(CourseArchiveStatus)
@@ -53,3 +57,48 @@ def course_key(self, obj: CourseArchiveStatus) -> str:
5357
operators recognize.
5458
"""
5559
return str(obj.course_run.course_key)
60+
61+
62+
@admin.register(UnitRating)
63+
class UnitRatingAdmin(admin.ModelAdmin):
64+
"""Admin for individual user-by-unit ratings."""
65+
66+
list_display = ("usage_key", "user", "stars", "course_key", "updated_at")
67+
list_filter = ("stars",)
68+
search_fields = (
69+
"usage_key",
70+
"course_run__course_key",
71+
"user__username",
72+
"user__email",
73+
)
74+
raw_id_fields = ("course_run", "user")
75+
readonly_fields = ("created_at", "updated_at")
76+
ordering = ("-updated_at",)
77+
78+
@admin.display(description="Course key", ordering="course_run__course_key")
79+
def course_key(self, obj: UnitRating) -> str:
80+
return str(obj.course_run.course_key)
81+
82+
83+
@admin.register(CourseAverageRating)
84+
class CourseAverageRatingAdmin(admin.ModelAdmin):
85+
"""Admin for the cached per-course rating aggregate."""
86+
87+
list_display = (
88+
"course_key",
89+
"average_stars",
90+
"rating_count",
91+
"sum_stars",
92+
"updated_at",
93+
)
94+
search_fields = ("course_run__course_key",)
95+
raw_id_fields = ("course_run",)
96+
# @@TODO: Right now sum/count/average are editable, which is useful for POC
97+
# debugging but in production they should be readonly so operators don't
98+
# accidentally desync them from the underlying UnitRating rows.
99+
readonly_fields = ("updated_at",)
100+
ordering = ("-updated_at",)
101+
102+
@admin.display(description="Course key", ordering="course_run__course_key")
103+
def course_key(self, obj: CourseAverageRating) -> str:
104+
return str(obj.course_run.course_key)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django 5.2.13 on 2026-05-14 19:28
2+
3+
import django.core.validators
4+
import django.db.models.deletion
5+
import opaque_keys.edx.django.models
6+
from django.conf import settings
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('openedx_catalog', '0001_initial'),
14+
('openedx_plugin_sample', '0001_initial'),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name='CourseAverageRating',
21+
fields=[
22+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23+
('sum_stars', models.PositiveIntegerField(default=0)),
24+
('rating_count', models.PositiveIntegerField(default=0)),
25+
('average_stars', models.FloatField(blank=True, help_text='Null when rating_count == 0.', null=True)),
26+
('updated_at', models.DateTimeField(auto_now=True)),
27+
('course_run', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='average_rating', to='openedx_catalog.courserun')),
28+
],
29+
),
30+
migrations.CreateModel(
31+
name='UnitRating',
32+
fields=[
33+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
34+
('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)),
35+
('stars', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
36+
('created_at', models.DateTimeField(auto_now_add=True)),
37+
('updated_at', models.DateTimeField(auto_now=True)),
38+
('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')),
39+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_ratings', to=settings.AUTH_USER_MODEL)),
40+
],
41+
options={
42+
'ordering': ['-updated_at'],
43+
'constraints': [models.UniqueConstraint(fields=('user', 'usage_key'), name='unique_user_unit_rating')],
44+
},
45+
),
46+
]

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

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
"""
22
Database models for openedx_plugin_sample.
3+
4+
Two independent features live side-by-side in this app:
5+
6+
* ``CourseArchiveStatus`` -- per-learner "this course is archived" flag,
7+
consumed by the Learner Dashboard course-card listing.
8+
* ``UnitRating`` + ``CourseAverageRating`` -- learners optionally rate units
9+
1-5 stars; we keep a cached per-course average that is updated incrementally
10+
on each write and fully recomputed when a course is republished.
311
"""
412

513
from django.contrib.auth import get_user_model
6-
from django.db import models
14+
from django.core.validators import MaxValueValidator, MinValueValidator
15+
from django.db import models, transaction
16+
from django.db.models import Count, Sum
17+
from opaque_keys.edx.django.models import UsageKeyField
718
from openedx_catalog.models import CourseRun
819

920

@@ -68,3 +79,135 @@ class Meta:
6879
fields=["course_run", "user"], name="unique_user_course_archive_status"
6980
)
7081
]
82+
83+
84+
class UnitRating(models.Model):
85+
"""
86+
A single learner's 1-5 star rating of one unit (vertical block).
87+
88+
.. no_pii: Stores no PII directly, only FKs to user and course_run.
89+
"""
90+
91+
user = models.ForeignKey(
92+
get_user_model(),
93+
on_delete=models.CASCADE,
94+
related_name="unit_ratings",
95+
)
96+
course_run = models.ForeignKey(
97+
CourseRun,
98+
on_delete=models.CASCADE,
99+
related_name="unit_ratings",
100+
help_text="Denormalized from usage_key so we can aggregate per course cheaply.",
101+
)
102+
usage_key = UsageKeyField(
103+
max_length=255,
104+
db_index=True,
105+
help_text="e.g. 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@abc123'",
106+
)
107+
stars = models.PositiveSmallIntegerField(
108+
validators=[MinValueValidator(1), MaxValueValidator(5)],
109+
)
110+
created_at = models.DateTimeField(auto_now_add=True)
111+
updated_at = models.DateTimeField(auto_now=True)
112+
113+
class Meta:
114+
constraints = [
115+
models.UniqueConstraint(
116+
fields=["user", "usage_key"],
117+
name="unique_user_unit_rating",
118+
),
119+
]
120+
ordering = ["-updated_at"]
121+
122+
def __str__(self):
123+
return f"{self.user.username} rated {self.usage_key}: {self.stars}*"
124+
125+
126+
class CourseAverageRating(models.Model):
127+
"""
128+
Cached aggregate of all UnitRatings for a CourseRun.
129+
130+
Kept in sync incrementally by ``apply_rating_delta`` (called from the API on
131+
create/update/destroy) and fully recomputed by ``recompute_from_scratch``
132+
on course publish.
133+
134+
.. no_pii: Aggregate only, no per-user data.
135+
"""
136+
137+
course_run = models.OneToOneField(
138+
CourseRun,
139+
on_delete=models.CASCADE,
140+
related_name="average_rating",
141+
)
142+
# Stored sum + count enables O(1) incremental updates without re-aggregating.
143+
sum_stars = models.PositiveIntegerField(default=0)
144+
rating_count = models.PositiveIntegerField(default=0)
145+
average_stars = models.FloatField(
146+
null=True,
147+
blank=True,
148+
help_text="Null when rating_count == 0.",
149+
)
150+
updated_at = models.DateTimeField(auto_now=True)
151+
152+
def __str__(self):
153+
if self.rating_count == 0:
154+
return f"{self.course_run.course_key}: no ratings yet"
155+
return (
156+
f"{self.course_run.course_key}: {self.average_stars:.2f}* "
157+
f"({self.rating_count} ratings)"
158+
)
159+
160+
def _refresh_average(self):
161+
"""Recompute ``average_stars`` from current ``sum_stars`` / ``rating_count``."""
162+
if self.rating_count == 0:
163+
self.average_stars = None
164+
else:
165+
self.average_stars = self.sum_stars / self.rating_count
166+
167+
def recompute_from_scratch(self, allowed_usage_keys=None):
168+
"""
169+
Rebuild sum/count/average by scanning UnitRating rows for this course_run.
170+
171+
Args:
172+
allowed_usage_keys: Optional iterable of usage_keys to restrict the
173+
aggregation to. Intended for the publish handler so that
174+
ratings for units that have since been removed are excluded.
175+
If None, all UnitRating rows for the course_run are included.
176+
"""
177+
qs = UnitRating.objects.filter(course_run=self.course_run)
178+
if allowed_usage_keys is not None:
179+
qs = qs.filter(usage_key__in=list(allowed_usage_keys))
180+
agg = qs.aggregate(total=Sum("stars"), n=Count("id"))
181+
self.sum_stars = agg["total"] or 0
182+
self.rating_count = agg["n"] or 0
183+
self._refresh_average()
184+
self.save()
185+
186+
187+
def apply_rating_delta(course_run, *, old_stars, new_stars):
188+
"""
189+
Update the cached CourseAverageRating to reflect a single rating change.
190+
191+
Pass ``old_stars=None`` for a brand-new rating, ``new_stars=None`` for a
192+
deletion, or both non-None for an update.
193+
194+
Wrapped in ``transaction.atomic`` + ``select_for_update`` so concurrent
195+
rating writes against the same course don't race on the cached aggregate.
196+
"""
197+
if old_stars is None and new_stars is None:
198+
return
199+
200+
with transaction.atomic():
201+
avg, _ = CourseAverageRating.objects.select_for_update().get_or_create(
202+
course_run=course_run,
203+
)
204+
if old_stars is None:
205+
avg.sum_stars += new_stars
206+
avg.rating_count += 1
207+
elif new_stars is None:
208+
avg.sum_stars = max(0, avg.sum_stars - old_stars)
209+
avg.rating_count = max(0, avg.rating_count - 1)
210+
else:
211+
avg.sum_stars = max(0, avg.sum_stars - old_stars) + new_stars
212+
avg._refresh_average()
213+
avg.save()

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
import crum
4343
from openedx_filters.filters import PipelineStep
4444

45-
from .models import CourseArchiveStatus
45+
from .models import CourseArchiveStatus, CourseAverageRating
4646

4747
logger = logging.getLogger(__name__)
4848

@@ -89,3 +89,50 @@ def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=argumen
8989
"isArchivedByLearner": is_archived_by_learner,
9090
},
9191
}
92+
93+
94+
class AddAverageRatingToLearnerHomeCourseRun(PipelineStep):
95+
"""
96+
Decorate each courseRun in the Learner Home /init response with the cached
97+
average rating, so the Archive course-card display can render stars without
98+
a second API call.
99+
100+
Filter name: ``org.openedx.learning.home.courserun.api.rendered.started.v1``
101+
"""
102+
103+
def run_filter(self, serialized_courserun, **kwargs): # pylint: disable=arguments-differ
104+
"""
105+
Args:
106+
serialized_courserun (dict): One courseRun from the /init serializer.
107+
Reads ``courseId``; passes all other fields through unchanged.
108+
109+
Returns:
110+
dict: ``{"serialized_courserun": <updated dict>}`` with two new keys
111+
added:
112+
- ``averageStars`` (float or None) -- None when no ratings exist
113+
- ``ratingCount`` (int) -- 0 when no ratings exist
114+
"""
115+
course_id = serialized_courserun.get("courseId")
116+
if not course_id:
117+
return {"serialized_courserun": serialized_courserun}
118+
119+
# @@TODO: looking up one row per courseRun is fine for a dashboard with a
120+
# handful of courses but will N+1 on long course lists. A prefetch in
121+
# the upstream serializer (or a bulk lookup here) would be the prod fix.
122+
try:
123+
avg = CourseAverageRating.objects.get(
124+
course_run__course_key=course_id,
125+
)
126+
average_stars = avg.average_stars
127+
rating_count = avg.rating_count
128+
except CourseAverageRating.DoesNotExist:
129+
average_stars = None
130+
rating_count = 0
131+
132+
return {
133+
"serialized_courserun": {
134+
**serialized_courserun,
135+
"averageStars": average_stars,
136+
"ratingCount": rating_count,
137+
},
138+
}

0 commit comments

Comments
 (0)