Skip to content

Add a course waffle flag and endpoint for the Contact us launcher - #931

Merged
zamanafzal merged 11 commits into
mainfrom
zafzal/13992-contact-us-waffle-flag
Nov 2, 2026
Merged

zamanafzal merged 11 commits into
mainfrom
zafzal/13992-contact-us-waffle-flag

Conversation

@zamanafzal

@zamanafzal zamanafzal commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

Refs mitodl/hq#14033, under mitodl/hq#13712. #14033 shipped the launcher behind a
build-time gate, which means turning it off needs a frontend rebuild and a redeploy.
This replaces that half of #14033 with a runtime switch; the rest of its acceptance
criteria still stand.

Description (What does it do?)

  • ol_openedx_chat.contact_us_enabled, a CourseWaffleFlag — site-wide, or overridden
    per course or per org from Django admin
  • GET /ol-openedx-chat/api/contact-us-enabled/[{course_key}] → {"enabled": <bool>},
    or 400 for a course key that routes but doesn't parse
  • Releases 0.7.0

No new plugin — this rides in ol_openedx_chat, which already owns the chat aside and
its own waffle flag. Operating notes for the flag (including two ways it silently
doesn't do what you expect) are in the plugin README.

Implementation details

authentication_classes is emptied rather than left at the default, so an expired
credential cannot produce a 401 that a fail-closed client reads as "off" — the launcher
would vanish for exactly the people whose session had just lapsed. This is not
theoretical: edxapp's DEFAULT_AUTHENTICATION_CLASSES puts DefaultJwtAuthentication
first (openedx/envs/common.py#L822-L825),
and it raises AuthenticationFailed on a bad JWT cookie regardless of AllowAny.

The cost is that the view is anonymous to waffle. CourseWaffleFlag picks the request up
through crum.get_current_request(), but DRF has already written AnonymousUser through
to that same HttpRequest, so superusers, staff and authenticated can never match —
a signed-in superuser gets the same answer as a logged-out learner.
test_signed_in_superuser_does_not_flip_a_per_user_flag pins that through crum's real
middleware. The caller-independent fields waffle falls through to (testing, languages,
percent) are still evaluated, so everyone is the field to set, not the only one that
can turn the flag on.

cache_control(public=True, max_age=60) bounds staleness after a flip. LMS middleware
stamps Vary: Cookie regardless of what the view declares, so a shared cache keys on the
cookie anyway and max-age is what actually bounds it.

Two URL patterns, each accepting an optional trailing slash, because the footer builds the
URL by concatenating a possibly-empty course key onto the base. The keyed form uses
settings.COURSE_KEY_PATTERN rather than a hand-rolled regex, and a test asserts that. A
segment failing the pattern 404s before reaching the view; one that passes but is not a
real course key reaches the view and gets the 400.

PluginURLs.CONFIG is registered for ProjectType.LMS only — Studio reads the endpoint
cross-origin from the LMS.

Screenshots (if appropriate):

Screenshot 2026-10-29 at 5 39 45 PM Screenshot 2026-10-29 at 5 39 54 PM

How can this be tested?

./run_edx_integration_tests.sh --plugin ol_openedx_chat

Runs the suite once per settings module, so you get two summaries:

Settings Result
lms.envs.test 71 passed, 9 skipped
cms.envs.test 76 passed, 4 skipped

All 24 new tests pass on both legs. Every skip is a pre-existing test_aside.py case; the
count differs because more of them skip under LMS settings.

New tests cover the flag accessor; both URL patterns with and without a trailing slash plus
a prefix-injection attempt; the full precedence chain (course beats org beats site-wide,
both directions); a disabled override falling back to site-wide; a valid key for a
nonexistent course returning 200; a+b+c returning 400; anonymous access; and the
Cache-Control header.

Against a local Tutor stack, with BASE=<your-lms-host>/ol-openedx-chat/api/contact-us-enabled
and FLAG=ol_openedx_chat.contact_us_enabled:

Step Command Expected
Before provisioning curl -s $BASE/ {"enabled":false}
Turn on site-wide manage.py lms waffle_flag $FLAG --create --everyone —
Site-wide and per course curl -s $BASE/ and curl -s $BASE/course-v1:edX+DemoX+2024 {"enabled":true} both
Force Off on one course add a Waffle flag course override in Django admin, choice off, enabled that course false, siblings still true
Unroutable key curl -s -o /dev/null -w '%{http_code}' $BASE/course-v2:a+b+c 400
Turn off manage.py lms waffle_flag $FLAG --deactivate {"enabled":false}

waffle_flag has no --activate; --everyone is what turns a flag on. A bare --create
leaves everyone null, which reads as off — set it explicitly so the row says "off on
purpose" rather than "nobody decided".

curl -i on any of the above shows Cache-Control: public, max-age=60 and
Vary: Accept, Accept-Language, origin, Cookie. A request carrying
Origin: http://apps.local.openedx.io:2000 gets that origin echoed back.

Additional Context

The endpoint is not throttled, deliberately. A 429 is indistinguishable from "off" to a
fail-closed client, so a rate limit would present as a feature outage. The counter-argument
is real — a keyed request costs two uncached queries (WaffleFlagCourseOverrideModel.override_value
is @request_cached(), which is per-request only) on an unauthenticated route, and the
sibling ol_openedx_auto_select_language.CourseLanguageView does throttle its equivalent.
Tracking separately rather than holding this PR; reviewers who want it done here, say so.

Flipping this during an incident has two traps — Pulumi reverts an admin change on the
next edxapp rollout, and editing the Pulumi value doesn't itself flip the flag. Both are
written up in the README.

Checklist:

Deploy order across repos — each step depends on the one before it.

Pulumi goes before the image because a waffle-flags-only Pulumi run rewrites the ConfigMap
and nothing re-reads it; the image deploy is what re-runs the Job. The footer goes last
because with no flag row the endpoint answers false and the client is fail-closed, so
shipping it first removes the launcher instead of gating it.

Only visible in mitxonline QA — the one environment with contact_us_support_api_url
set. Elsewhere the launcher does not render at all, so getting the order wrong is invisible.

Copilot AI balanced review requested due to automatic review settings October 28, 2026 12:36

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.

🟡 Changes recommended

Removing all DRF authenticators anonymizes valid session users, preventing superuser, staff, group, and user-specific feature-flag rules from working when everyone=None.

1 open issue
What changed in this PR

Adds runtime control for the MFE Contact us launcher through the existing ol_openedx_chat plugin, allowing operators to toggle it without rebuilding the frontend.

Changes:

  • Adds a course waffle flag and an LMS endpoint for querying its effective value.
  • Adds tests for routing, flag precedence, validation, anonymous access, and caching.
  • Documents flag operations and bumps the plugin to version 0.7.0.
File Description
uv.lock Updates the locked plugin version to 0.7.0.
src/​ol_openedx_chat/​tests/​test_views.py Tests flag evaluation and endpoint responses.
src/​ol_openedx_chat/​tests/​test_urls.py Tests keyed and course-less routes.
src/​ol_openedx_chat/​tests/​test_compat.py Checks the new flag’s name and independence.
src/​ol_openedx_chat/​tests/​fixtures.py Adds a shared request factory fixture.
src/​ol_openedx_chat/​tests/​conftest.py Exposes the shared fixture to tests.
src/​ol_openedx_chat/​README.rst Documents flag precedence and deployment behavior.
src/​ol_openedx_chat/​pyproject.toml Bumps the package version to 0.7.0.
src/​ol_openedx_chat/​ol_openedx_chat/​views.py Adds the cacheable flag-status endpoint.
src/​ol_openedx_chat/​ol_openedx_chat/​urls.py Defines routes with optional course keys.
src/​ol_openedx_chat/​ol_openedx_chat/​compat.py Adds the Contact us flag accessor.
src/​ol_openedx_chat/​ol_openedx_chat/​apps.py Registers the plugin’s LMS routes.
src/​ol_openedx_chat/​CHANGELOG.rst Records the new flag and endpoint release.

Review effort: Balanced

The note claimed an unset flag shows the launcher to every superuser. It does
not. The view sets no authentication classes, so DRF resolves the caller to
AnonymousUser and writes it back onto the Django request that crum hands to
CourseWaffleFlag; superusers, staff and authenticated can never match, verified
by sending a superuser session cookie with all three set true and still getting
{"enabled": false}. everyone and the course overrides are the only live levers.
The README and the Pulumi PR both claimed this and nothing asserted it. Every
existing test calls the view directly, so crum sees no request and the
production `flag_is_active(request, ...)` path never ran. Drives the view
through crum's real middleware with a real Flag row carrying
superusers/staff/authenticated; dropping `authentication_classes = []` flips it
to enabled=true, so it catches the regression it is there for.

The views.py and README notes said the opposite - that waffle still evaluates
the per-user path. DRF writes AnonymousUser through to the HttpRequest, so it
cannot. The caller-independent fields (testing, languages, percent) are still
evaluated, which the README now says instead of "everyone is the only field".
Both patterns end in $ and are endpoints, so Django already fullmatches them
and the leading ^ changes no routing - test_urls.py's prefix-injection case
passes either way. Adding it anyway to match ol_openedx_course_export, which
anchors both ends.

The 0.7.0 "Added" heading used the same underline character as "Change Log",
so it rendered as a sibling of the log rather than nested under the version.

@asadali145 asadali145 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.

Looks good to me, thanks! Verified live on a local Tutor LMS: course-less and keyed routes, 400/404 handling, everyone/course-override precedence, and that a signed-in session survives the anonymized request.

@asadali145 asadali145 self-assigned this Oct 30, 2026
@zamanafzal
zamanafzal merged commit 129c9db into main Nov 2, 2026
13 checks passed
@zamanafzal
zamanafzal deleted the zafzal/13992-contact-us-waffle-flag branch November 2, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants