Org Web Adapter

hungryroot/popsicle/homefeed-unauthenticated-support.org

ID
f3b8e6a2-7c1d-4e9a-9b3f-2a6d5c8e1f47

HomeFeed APIs: supporting unauthenticated users

Context

~home_feed~ exposes two GET endpoints, both hard-requiring an authenticated, account-holding

customer:

- ~HomeFeedView~ (~home_feed/views/home_feed_view.py:42~) — calls SmartCart RecSys for a

personalized, ranked feed.

- ~MarketingItemsView~ (~home_feed/views/marketing_items_view.py:32~) — filters the

~HomeFeedConfig.MARKETING_ITEMS~ dynamic config list down to items eligible for the customer.

Both use ~permission_classes = [IsStaff | IsCustomerPathAuthorized]~, and

~IsCustomerPathAuthorized~ / ~CustomerModelMixin.parse_customer_id()~ unconditionally reject

unauthenticated requests (~app/rest/permissions.py:85~, ~app/rest/mixins.py:24~). The ask: let

logged-out visitors get a home feed too (e.g. a pre-signup browsing experience) — scoped to

*both endpoints*, serving a *generic/default (non-personalized) feed* to anonymous visitors, with

no new guest-identity or session model.

Key constraint discovered

~MarketingItemsService~ (~home_feed/services/marketing_items_service.py:27~) was already built

with a seam for this: its ~__init__~ takes explicit ~serving_size~/~diet~/exclusion params, and

~from_customer()~ is just one way to populate them — "Filter inputs are explicit, so eligibility

can be evaluated either for a stored customer... or for a synthetic set of preferences with no

customer" (line 36). Sensible no-customer defaults already exist elsewhere in the codebase:

~DEFAULT_SERVING_SIZE = 2~ (~app/const.py:1888~), and ~{}~ is a valid "no restrictions"

~DietaryPreferenceDict~ (empty dict skips all restriction filters in

~customer_dietary_service.filter_by_dietary_dict~).

~HomeFeedService~, by contrast, calls SmartCart's ~/recommendations/home-feed~ endpoint, whose

request schema (~home_feed/shared/schema_dtos/sc_home_feed_request.py:29~) declares

~customer_id: int~ as *required*. There's no legitimate customer_id to send for a real anonymous

visitor, and SmartCart/RecSys is owned by another team — this repo can't unilaterally make that

call support anonymous requests. A true SmartCart-personalized feed for logged-out visitors is

out of scope for this repo alone.

*Recommendation:* for anonymous requests, don't call SmartCart at all. Serve the same

non-personalized item set ~MarketingItemsService~ already produces (curated dynamic-config items,

filtered only by default serving size — no dietary/tag/favorite/cart exclusions since there's no

customer data to exclude) as the anonymous "home feed" content too. Flag true SmartCart-backed

anonymous recs as a follow-up dependency on the RecSys team if deeper personalization is wanted

later.

Design

Follow the existing public-endpoint convention from ~discovery_home~

(~V3PublicCollectionListView~ / ~V3PublicCollectionDetailView~ in

~discovery_home/views/v3/collection.py~, routed without a ~customers/<id>/~ prefix and

~permission_classes = [AllowAny]~), rather than overloading ~customers/self/...~ (which

~CustomerModelMixin.parse_customer_id()~ explicitly rejects for unauthenticated users by design).

1. ~MarketingItemsService~ — add a no-customer constructor

In ~home_feed/services/marketing_items_service.py~, add a classmethod alongside

~from_customer()~:

#+begin_src python

@classmethod

def from_defaults(cls) -> MarketingItemsService:

"""Build the service with no customer context, using default serving size and no restrictions."""

return cls(serving_size=DEFAULT_SERVING_SIZE, diet={})

#+end_src

(~DEFAULT_SERVING_SIZE~ from ~app.const~.) All other params (~never_product_ids~,

~disliked_tag_ids~, ~excluded_*_ids~) already default to empty sets in ~__init__~.

2. New public views

Add to ~home_feed/views/~:

- ~PublicMarketingItemsView~ — mirrors ~MarketingItemsView~ but no ~CustomerModelMixin~,

~permission_classes = [AllowAny]~, calls

~MarketingItemsService.from_defaults().get_marketing_items()~.

- ~PublicHomeFeedView~ — same shape as ~HomeFeedView~ (same param/response serializers,

pagination), ~permission_classes = [AllowAny]~, but delegates to

~MarketingItemsService.from_defaults().get_marketing_items()~ instead of ~HomeFeedService~

(no SmartCart call). Update its docstring/~extend_schema~ description to state it returns

non-personalized items, not SmartCart-ranked recs.

3. Routing

In ~home_feed/urls.py~, add routes without the ~customers/<str:customer_id>/~ prefix, e.g.:

#+begin_src python

path("home_feed/", PublicHomeFeedView.as_view(), name="v3-public-home-feed-list"),

path("home_feed/marketing_items/", PublicMarketingItemsView.as_view(), name="v3-public-home-feed-marketing-items-list"),

#+end_src

Exact path segments to be confirmed against any existing frontend/API-contract expectations —

check with whoever owns the client integration before finalizing the paths.

4. Tests

- ~home_feed/tests/services/test_marketing_items_service.py~ — add coverage for

~from_defaults()~ (default serving size applied, no exclusions, dietary filter is a no-op).

- ~home_feed/tests/views/~ — add ~test_public_home_feed_view.py~ / extend

~test_marketing_items_view.py~ with an unauthenticated-request test hitting the new public

routes and asserting a 200 with the marketing-items-derived payload, plus a regression test that

the existing customer-scoped routes still 401/403 for anonymous requests.

Verification

- ~python manage.py test home_feed~ for the new/updated test files.

- Manually hit the new public URLs with no session cookie (e.g. via ~curl~ or an incognito

browser tab against local dev) and confirm a 200 with paginated items; confirm the existing

~customers/<id>/home_feed/~ and ~customers/<id>/marketing_items/~ routes still require auth.

- ~prek run ruff-format~ / ~prek run ruff~ on all modified files.