Skip to content

feat: add content feedback model and submission endpoint - #3593

Merged
zamanafzal merged 3 commits into
mainfrom
zafzal/11629-content-feedback-mitlearn
Jul 20, 2026
Merged

feat: add content feedback model and submission endpoint#3593
zamanafzal merged 3 commits into
mainfrom
zafzal/11629-content-feedback-mitlearn

Conversation

@zamanafzal

@zamanafzal zamanafzal commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

Part of the per-block content feedback effort — RFC mitodl/hq#11812 (parent issue mitodl/hq#11629).

Companion PRs:

  • mitodl/smoot-design#241 — feedback drawer UI bundle
  • mitodl/open-edx-plugins#813 — in-iframe "Send feedback" trigger (ol_openedx_feedback)
  • mitodl/lehrer#83 — Learning MFE slot wiring

Supersedes the earlier learn-ai backend PR mitodl/learn-ai#556 (to be closed).

Description (What does it do?)

Adds the mit-learn backend that persists per-block content feedback submitted from the Learning MFE. New self-contained content_feedback app, following the conventions of small single-purpose apps like testimonials.

  • ContentFeedback modeluser (FK, nullable, on_delete=SET_NULL so feedback outlives a deleted account), course_id, course_name, block_usage_key, block_type, block_display_name, unit_title, url, sentiment (positive / negative / idea), comment. Composite index on (course_id, created_on); block_usage_key indexed.
  • Append-only — every submission is kept as its own date-stamped record (per @pdpinch's call in the RFC thread), preserving full history. A learner may submit on the same block more than once; "latest reaction wins" is a read-time / data-platform concern (take the most recent by created_on), not enforced in the schema.
  • POST /api/v0/content_feedback/ — a DRF CreateAPIView (IsAuthenticated); perform_create attributes the record to the authenticated user server-side (user is never client-settable). Returns 201 with the created record, or 400 on validation errors. Auth is the same APISIX x-userinfo → session mechanism used across mit-learn.
  • ContentFeedbackSerializersentiment validated against the ContentFeedbackSentiment enum; course_id / block_usage_key / sentiment required; comment truncated to 1000 chars (not rejected).
  • Read-only Django admin (no add/delete, ordered by created_on), a factory, a single 0001_initial migration, and 11 view/serializer tests.

Screenshots:

Screenshot 2026-07-15 at 5 02 29 PM

How can this be tested?

1. Automated (standalone — no gateway/auth needed):

docker compose exec web python manage.py migrate content_feedback
docker compose exec web pytest content_feedback/

All 11 tests pass. They cover: auth required, record created & attributed to the request user, user not client-settable, required-field validation, invalid sentiment rejected, append-only resubmit keeps every record (2 rows, history preserved), distinct blocks kept separate, comment truncation, factory validity.

2. Live endpoint (through APISIX, real auth path):

The endpoint is IsAuthenticated + SessionAuthentication + CSRF, with the user established by APISIX's injected X-Userinfo. It therefore can't be curl'd cold — you need a logged-in session and a CSRF token. The browser console is easiest (it carries the session cookie automatically):

  1. docker compose up -d, then log in: open http://open.odl.local:8065/login and complete Keycloak. (Hitting /api/... won't prompt — only /login triggers the OIDC login. Sessions lapse, so if you get NotAuthenticated, log in again.)
  2. On the open.odl.local:8065 origin, open the devtools console and run the snippet below. Note the local CSRF cookie is csrftoken-local (deployment-prefixed, env-configurable — not the bare csrftoken); the header is X-CSRFToken. GET /api/v0/users/me/ is @ensure_csrf_cookie, so it primes the cookie. The body below sends every accepted field (course_id, block_usage_key, sentiment are required; the rest are optional context the MFE enriches):
(async () => {
  const me = await fetch("http://open.odl.local:8065/api/v0/users/me/", { credentials: "include" });
  if (me.status !== 200) { console.warn("Not logged in (", me.status, ") → visit /login first"); return; }
  console.log("authed as:", (await me.json()).username);
  const csrf = document.cookie.match(/(?:^|; )csrftoken-local=([^;]+)/)?.[1];
  const res = await fetch("http://open.odl.local:8065/api/v0/content_feedback/", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json", "X-CSRFToken": csrf },
    body: JSON.stringify({
      course_id: "course-v1:MITx+6.00+2T2026",
      course_name: "Introduction to Computer Science",
      block_usage_key: "block-v1:MITx+6.00+2T2026+type@video+block@abc123",
      block_type: "video",
      block_display_name: "Lecture 3: Recursion",
      unit_title: "Recursion and Dictionaries",
      url: "https://apps.mitxonline.mit.edu/learn/course/course-v1:MITx+6.00+2T2026/block-v1:.../abc123",
      sentiment: "idea",
      comment: "Add a worked example",
    }),
  });
  console.log(res.status, await res.json());   // expect 201 + the created record
})();

Expect 201 with the validated record and a new ContentFeedback row attributed to the authenticated user. POSTing again for the same block creates an additional row (append-only). This is the same mechanism the MFE drawer uses (credentials + CSRF token echoed from the cookie).

This data can be viewed at http://open.odl.local:8065/admin/content_feedback/

Additional Context

Open items before go-live (integration, handled outside this PR):

  • MFE → mit-learn submit auth (CSRF). The Learning MFE submits cross-origin. The plan is to prime the CSRF cookie via GET /api/v0/users/me/ (@ensure_csrf_cookie) rather than the edX-style /csrf/api/v1/token prefetch; the client wiring needs that swap.
  • Deploy config. The Learning-MFE origin must be added to CORS_ALLOWED_ORIGINS and CSRF_TRUSTED_ORIGINS (both env-driven — no code change here).
  • MFE FEEDBACK_SUBMIT_URL points at this endpoint once it lands.
  • learning_resources FK intentionally omitted for v1 (a leaf block_usage_key has no guaranteed catalog row); the model can add a nullable FK later.
  • Spam control — append-only + per-user request throttling before enabling (tracked in mitodl/hq#12355); no DB-level dedupe by design.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

OpenAPI Changes

1 changes: 0 error, 0 warning, 1 info

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@zamanafzal
zamanafzal marked this pull request as ready for review July 10, 2026 10:52
Copilot AI review requested due to automatic review settings July 10, 2026 10:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new self-contained Django app (content_feedback) that persists per-block learner feedback via a new authenticated DRF create endpoint, and updates the OpenAPI spec + generated TypeScript client to expose the new API contract.

Changes:

  • Introduces content_feedback Django app with ContentFeedback model, admin configuration, serializer, URL routing, and DRF CreateAPIView.
  • Adds API tests (including append-only resubmission and comment truncation behavior) plus a Factory Boy factory.
  • Updates openapi/specs/v0.yaml and regenerates frontends/api v0 client types/APIs for the new endpoint and sentiment enum.

Reviewed changes

Copilot reviewed 13 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
openapi/specs/v0.yaml Adds /api/v0/content_feedback/ path and schemas (including SentimentEnum) to the v0 OpenAPI spec.
main/urls.py Wires content_feedback URLs into the main Django URL config.
main/settings.py Registers the new content_feedback app in INSTALLED_APPS.
frontends/api/src/generated/v0/api.ts Regenerates the TS API client/types to include ContentFeedback endpoint + models.
content_feedback/views.py Implements authenticated append-only submission endpoint (CreateAPIView).
content_feedback/views_test.py Adds endpoint tests for auth, validation, append-only behavior, and truncation.
content_feedback/urls.py Defines v0 URL routing for the new endpoint.
content_feedback/serializers.py Defines ContentFeedbackSerializer including comment truncation logic.
content_feedback/models.py Adds ContentFeedback model + indexes.
content_feedback/migrations/0001_initial.py Creates initial DB schema for ContentFeedback.
content_feedback/factories.py Adds ContentFeedbackFactory for tests.
content_feedback/constants.py Defines sentiment enum and comment max length constant.
content_feedback/apps.py Declares app config.
content_feedback/admin.py Adds read-only Django admin for viewing submissions.
content_feedback/init.py Initializes package.
content_feedback/migrations/init.py Initializes migrations package.

Comment thread content_feedback/admin.py Outdated
@zamanafzal
zamanafzal force-pushed the zafzal/11629-content-feedback-mitlearn branch 2 times, most recently from a7e0963 to f495fd2 Compare July 10, 2026 11:11
@zamanafzal

Copy link
Copy Markdown
Contributor Author

Good catch — addressed in 254aa8c: dropped course_id from list_filter (moved it to search_fields), keeping sentiment as the only filter so the admin changelist stays performant as feedback accumulates.

@zamanafzal
zamanafzal force-pushed the zafzal/11629-content-feedback-mitlearn branch from aa7c61c to 254aa8c Compare July 15, 2026 08:23
from main.models import TimestampedModel


class ContentFeedback(TimestampedModel):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future extension: generalizing to non-course ("feedback on mit-learn itself", Appzi-style)

This app is intentionally scoped to per-block course content feedback. If/when we need to collect feedback on mit-learn pages themselves (search, resource detail, features), the plan is to extract the shared fields into an abstract base and add a sibling concrete model — not to overload ContentFeedback:

BaseFeedback (abstract)     # user, comment, url, created_on, updated_on
├── ContentFeedback         # + course_id, block_usage_key, block_type, …, sentiment
└── SiteFeedback (new)      # + page_type/route, resource_id (or GenericFK), rating/nps

Each domain keeps its own flat, queryable columns (good for the data platform); shared logic lives once in the base.

Why later, not now:

  • No second consumer yet. The right shared shape is only knowable once site-feedback requirements are real (e.g. does it use sentiment or an NPS/star rating? key off url or a resource id?). Abstracting today means guessing — and a wrong abstraction costs more than none.

  • Retrofitting is near-free. An abstract base creates no table of its own, so extracting it later is a no-op/state-only migration — existing ContentFeedback rows are untouched, and SiteFeedback is a purely additive new table.

  • Keeps this PR focused. Speculative inheritance adds review surface for an unscoped use case with zero functional gain today.

@zamanafzal
zamanafzal force-pushed the zafzal/11629-content-feedback-mitlearn branch from 254aa8c to 160c7d5 Compare July 15, 2026 09:48
@zamanafzal zamanafzal added the Needs Review An open Pull Request that is ready for review label Jul 15, 2026
New content_feedback app: ContentFeedback model (append-only, user set
server-side, on_delete=SET_NULL), POST /api/v0/content_feedback/ via
CreateAPIView with enum/required-field validation and comment truncation,
read-only admin, single 0001 migration, factory, and 11 tests. Registers
the app in settings/urls and regenerates the OpenAPI spec + v0 TS client.
@zamanafzal
zamanafzal force-pushed the zafzal/11629-content-feedback-mitlearn branch from 160c7d5 to ac1e008 Compare July 15, 2026 17:10
@mbertrand mbertrand self-assigned this Jul 16, 2026

@mbertrand mbertrand left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, I think it would be good to change the url field from a TextField to a URLField or CharField, the other comments are just minor nitpicks.

Comment thread content_feedback/models.py Outdated
block_type = models.CharField(max_length=64, blank=True)
block_display_name = models.CharField(max_length=255, blank=True)
unit_title = models.CharField(max_length=255, blank=True)
url = models.TextField(blank=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider URLField(max_length=2083, blank=True) here — bounds the column (TextField is unbounded, so any authed user can store arbitrarily large values) and validates format at the serializer layer for free. 2083 matches the URL-ish fields elsewhere in the repo. Cheap to do now while 0001_initial hasn't shipped.

Comment thread content_feedback/admin.py
Comment on lines +38 to +42
def has_add_permission(self, request): # noqa: ARG002
"""Disallow creating records in the admin; they come only via the API."""
return False

def has_delete_permission(self, request, obj=None): # noqa: ARG002

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add/delete are disabled but the change form still saves — with everything in readonly_fields it's a no-op edit, except it bumps updated_on on an append-only record. Adding has_change_permissionFalse closes that; records stay browsable via the view permission.

block_display_name = models.CharField(max_length=255, blank=True)
unit_title = models.CharField(max_length=255, blank=True)
url = models.TextField(blank=True)
sentiment = models.CharField(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: drf-spectacular names the spec component SentimentEnum, which is pretty generic for a global name — if another v0 serializer ever adds a sentiment field with different choices, it'll warn and auto-suffix. An ENUM_NAME_OVERRIDES entry (or naming it ContentFeedbackSentimentEnum) now is cheaper than renaming after the MFE imports the type.

@zamanafzal
zamanafzal force-pushed the zafzal/11629-content-feedback-mitlearn branch from 476c81f to 27144c1 Compare July 17, 2026 04:15
@zamanafzal
zamanafzal requested a review from mbertrand July 17, 2026 06:02
@zamanafzal

Copy link
Copy Markdown
Contributor Author

@mbertrand Could you please re-review when you get a chance, I've addressed the feedback.

@mbertrand mbertrand left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@zamanafzal
zamanafzal merged commit 380cc9c into main Jul 20, 2026
14 checks passed
@zamanafzal
zamanafzal deleted the zafzal/11629-content-feedback-mitlearn branch July 20, 2026 12:33
@odlbot odlbot mentioned this pull request Jul 20, 2026
12 tasks
mbertrand pushed a commit that referenced this pull request Jul 22, 2026
* feat: add content_feedback backend (append-only model + submit endpoint)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Review An open Pull Request that is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants