Skip to content

anonymous user handling - #3805

Open
gumaerc wants to merge 21 commits into
mainfrom
cg/anonymous-user-handling
Open

anonymous user handling#3805
gumaerc wants to merge 21 commits into
mainfrom
cg/anonymous-user-handling

Conversation

@gumaerc

@gumaerc gumaerc commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

https://github.com/mitodl/hq/issues/12530

Description (What does it do?)

Allows anonymous (logged-out) users to add items to a cart and begin checkout on MITx Online, converting the anonymous cart to a real account when the user authenticates - deferring account creation/onboarding until after they've already committed to checking out, rather than up front.

Basket model (ecommerce/models.py)

  • Basket.user is now nullable; added Basket.anonymous_id (UUID, unique).
  • A CheckConstraint enforces exactly one of user/anonymous_id is set, so a half-converted basket is unrepresentable at the DB level.
  • Added Basket.is_anonymous property.

Anonymous identity & basket lifecycle (ecommerce/api.py)

  • get_anonymous_basket_id(request, create=False) - reads/mints a UUID4 into request.session["anonymous_basket_id"]. Only mints when create=True, so a plain anonymous page view never gets a session cookie written (preserves CDN/cache-friendliness for logged-out traffic).
  • establish_basket_for_request(request, for_update=False) - single entry point for cart endpoints; dispatches to the existing establish_basket() for authenticated users, or get-or-creates by anonymous_id otherwise. for_update=True re-fetches with select_for_update() for mutating callers.
  • claim_anonymous_basket(request) - the conversion step. Reassigns the anonymous basket to the now-authenticated user, pops the session key, and applies any user discount (so an approved financial-assistance discount lands immediately, per the flow described in the ticket). If the user already had a basket, it's discarded in favor of the anonymous one (see Additional Context below - this is a judgment call, not something the ticket specifies).
  • cull_anonymous_baskets() - deletes anonymous baskets older than ANONYMOUS_BASKET_CULL_AGE (defaults to SESSION_COOKIE_AGE), wired into CELERY_BEAT_SCHEDULE to run daily.

Endpoints (ecommerce/views/legacy/__init__.py, ecommerce/urls.py)

  • CheckoutApiViewSet now uses per-action permissions (get_permissions()): add_to_cart, cart, and basket_items_count are AllowAny; redeem_discount stays IsAuthenticated (no discount-code entry for anonymous users, per the ticket).
  • cart and basket_items_count skip discount auto-apply for anonymous requests and never leak another user's basket when no anonymous session exists.
  • CheckoutProductView (/cart/add) no longer requires login.
  • New AnonymousCheckoutView at /checkout/anonymous/ - LoginRequiredMixin as defense-in-depth behind the APISIX auth requirement (see below); claims the anonymous basket and redirects to the existing /checkout/to_payment flow unchanged.

APISIX routing (config/apisix/apisix.yaml)

  • Removed the old /cart auth-required route so cart pages fall through to the general "pass" route.
  • Added a new unauth_action: auth route for /checkout/anonymous, so an unauthenticated hit is redirected straight into Keycloak by APISIX and returns directly to /checkout/anonymous/ (not through /login, which would otherwise trigger the new-user onboarding redirect before payment - onboarding is meant to happen after payment per the ticket).

Frontend (frontend/public/src)

  • OrderSummaryCard.js - new required isAuthenticated prop. Sends the user to /checkout/anonymous/ instead of /checkout/to_payment when logged out; the coupon-code form is not rendered at all for logged-out users.
  • CartPage.js - passes isAuthenticated through; suppresses the financial-assistance offer link when logged out.
  • OrderReceiptPage.js - passes isAuthenticated={true} (receipts only exist for completed, already-converted orders).
  • App.js - the cart-count badge and its query are no longer gated on auth, so it works for anonymous carts too (safe because basket_items_count never mints a session for a read-only count check).

How can this be tested?

Automated:

  • Backend: docker compose exec web pytest ecommerce/ (699 passing) - covers the model constraint, all three new api.py functions (including the login-survives-the-session, and basket-collision-on-claim scenarios), the anonymous-reachable endpoints, and the cleanup task.
  • Frontend: yarn test from frontend/public (466 passing) - covers OrderSummaryCard, CartPage, and App's badge-query change.

Manual, with mitxonline's own self-contained docker compose + local APISIX/Keycloak:

  1. Log out (or use an incognito session). Add a product to the cart via /cart/add or the API.
  2. Visit /cart - items should be visible, "Place your order" shown, no coupon field.
  3. Click "Place your order" - should land on /checkout/anonymous/, which APISIX should force through Keycloak login (new or existing account).
  4. After login, confirm you're redirected to /checkout/to_payment and the basket now belongs to your account (any approved FA discount should be applied).

Note: if your local dev setup instead points at an external Keycloak/APISIX (e.g. a mit-learn docker-compose stack), that repo's config/apisix/apisix.yaml needs the equivalent routing addition - this PR's diff only covers mitxonline's own routes.

Additional Context

  • Out of scope, needs a companion change in mit-learn: the "Enroll in X" button currently gates on auth before hitting /cart/add. That's frontend code in a different repository and isn't touched here.
  • Out of scope, separate PR: mirroring the APISIX routing change into the production Pulumi config in ol-infrastructure (src/ol_infrastructure/applications/mitxonline/__main__.py). This PR's /checkout/anonymous/ route will not be auth-protected in production until that PR lands - see checklist below.
  • Out of scope, per the ticket: updating the /api/v0/baskets/ JSON APIs for anonymous baskets - future work for when the cart/checkout UI moves into Learn.
  • Open question for reviewers: if a user already has an existing basket at the moment their anonymous basket is claimed, this PR discards the existing basket in favor of the anonymous one (last-viewed-cart wins). The ticket doesn't specify this case; flagging for explicit sign-off.
  • Pre-existing, unrelated bug found while reading this code: ecommerce/views/v0/__init__.py calls Basket.establish_basket(request), but no such classmethod exists on the model - this would raise on every call. Not touched here; worth its own ticket.

Checklist:

  • Land the companion ol-infrastructure Pulumi PR (production APISIX routing for /checkout/anonymous/) before or alongside deploying this to production - without it, the new route falls under the general "pass" rule and the onboarding-bypass behavior described above won't hold in prod.
  • Get explicit sign-off on the basket-collision behavior noted above.
  • File the companion mit-learn issue/PR for the "Enroll in X" button change. ([WIP] anonymous checkout mit-learn#3709)

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for v0.yaml:
30 changes: 24 error, 0 warning, 6 info
error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/
		the response property `items/discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/
		the response property `items/user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/
		the response property `items/discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/
		the response property `items/user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `user` became optional for the status `200`

info	[response-optional-property-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/
		added the optional property `items/anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/add_discount/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/baskets/create_with_products/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/baskets/{id}/
		added the optional property `anonymous_id` to the response with the `200` status



## Changes for v1.yaml:
30 changes: 24 error, 0 warning, 6 info
error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/
		the response property `items/discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/
		the response property `items/user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/
		the response property `items/discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/
		the response property `items/user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `user` became optional for the status `200`

info	[response-optional-property-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/
		added the optional property `items/anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/add_discount/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/baskets/create_with_products/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/baskets/{id}/
		added the optional property `anonymous_id` to the response with the `200` status



## Changes for v2.yaml:
30 changes: 24 error, 0 warning, 6 info
error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/
		the response property `items/discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/
		the response property `items/user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/
		the response property `items/discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/
		the response property `items/user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/add_discount/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_with_products/
		the response property `user` became optional for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `discounts/items/redeemed_basket/user` became nullable for the status `200`

error	[response-property-became-nullable] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `user` became nullable for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `discounts/items/redeemed_basket/user` became optional for the status `200`

error	[response-property-became-optional] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/{id}/
		the response property `user` became optional for the status `200`

info	[response-optional-property-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/
		added the optional property `items/anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/add_discount/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_from_product/{product_id}/{discount_code}/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/baskets/create_with_products/
		added the optional property `anonymous_id` to the response with the `200` status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/baskets/{id}/
		added the optional property `anonymous_id` to the response with the `200` status



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

@gumaerc gumaerc changed the title [WIP] anonymous user handling anonymous user handling Jul 31, 2026
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.

1 participant