- ID
- a0fe5c6a-37c3-41bb-a5ed-111e75f37fdd
Shareable User-Created Cookbooks
Context
Cookbooks today (~cookbooks/models.py:50-94~) are admin-managed, identified by ~slug~, and fully public when ~is_active=True~. No customer ownership, no per-cookbook visibility, no share tokens. Product wants users to create cookbooks and share them with a flexible URL pattern, with referral credit accruing to the sharer.
Decisions from clarifying questions:
- *Visibility*: tri-state enum --- ~private~ / ~unlisted~ / ~public~.
- *URL shape*: short opaque token, e.g. ~/c/aB3xK9p2Lq~.
- *Revocation*: token rotatable by owner.
- *Referral*: shares reuse existing ~Customer.referral_code~ rails; sharer earns standard referral bonus when a recipient signs up + first-orders.
This document covers only the share-pattern primitives (model fields, token gen, share-by-token read endpoint, rotate endpoint, serializer guard, referral wiring). User-cookbook CRUD scaffolding is a separate effort that will consume these primitives.
Data Model
Edit ~cookbooks/models.py~ ~Cookbook~ (lines 50--94):
1. Add ~owner~ FK -> Customer: ~null=True, blank=True, on_delete=SET_NULL, related_name="cookbooks"~. ~NULL~ = admin/global cookbook (preserves existing rows).
2. Add ~visibility~: ~CharField(choices=CookbookVisibility.choices, default=CookbookVisibility.PRIVATE, db_index=True)~. Define ~CookbookVisibility(TextChoices)~ near the top of ~cookbooks/models.py~ with ~PRIVATE~, ~UNLISTED~, ~PUBLIC~.
3. Add ~share_token~: ~CharField(max_length=16, unique=True, null=True, blank=True, db_index=True)~. Lazily set when the user first shares; rotating overwrites.
Migration
- Backfill existing admin cookbooks to ~visibility=PUBLIC~ (keeps current public-browsing behavior intact). Leave ~owner=NULL~ and ~share_token=NULL~.
- Single migration in ~cookbooks/migrations/~.
Token Generation
Use ~secrets.token_urlsafe(8)~ → 11-char URL-safe base64 (~64 bits entropy). Collision-retry loop on ~IntegrityError~ (3 tries) inside a helper. Reasonable for the foreseeable scale; revisit length only if collision rate becomes measurable.
Add ~cookbooks/share.py~ with:
- ~generate_share_token() -> str~
- ~assign_share_token(cookbook: Cookbook) -> str~ --- generates + saves with retry on unique violation; idempotent if token already set (returns existing).
- ~rotate_share_token(cookbook: Cookbook) -> str~ --- always generates a new one.
Endpoints
Add to ~cookbooks/urls_v3.py~:
~GET /api/v3/cookbooks/shared/<str:token>/~ → ~CookbookSharedDetailView~
- ~permission_classes = [AllowAny]~ (mirrors ~CookbookDetailView~ at ~cookbooks/views.py:541-559~).
- Looks up by ~share_token~. 404 if not found *or* if ~visibility == PRIVATE~ (token may still exist from a prior shared state; never reachable while private).
- Reuse a dedicated ~CookbookSharedSerializer~ (see below) so the share token and ~owner_referral_code~ don't leak via the general ~CookbookSerializer~.
~POST /api/v3/cookbooks/<int:pk>/share-token/rotate/~ → ~CookbookRotateShareTokenView~
- ~permission_classes = [IsAuthenticated]~, owner-only check in ~post()~.
- Calls ~rotate_share_token()~, returns new token in body.
Owner CRUD endpoints (list-mine, create, update visibility, delete) are out of scope here --- wire them when the user-cookbook feature lands.
Serializer Guard
In ~CookbookSerializer~ (~cookbooks/serializers.py:266~):
- Add ~share_token~ as a ~SerializerMethodField~ returning the token only when ~self.context["request"].user~ is the cookbook's ~owner~. Otherwise omitted/~None~.
- Prevents the unlisted token from leaking through any public response that happens to embed the cookbook.
Add ~CookbookSharedSerializer~ for the share endpoint specifically, exposing the public-safe cookbook fields plus ~owner_referral_code~ (see Referral Attribution).
Listing Filter
In ~CookbookListView~ (~cookbooks/views.py~, around line 515), default queryset filter should add ~visibility=PUBLIC~ for the public list. Admin/inactive-include path stays untouched. Confirm ~include_inactive~ admin override still works.
Frontend / URL Hint
Reserve ~/c/<token>~ as the canonical short share URL (frontend route → calls ~/api/v3/cookbooks/shared/<token>/~). Out of scope for this plan but flagged so the backend response shape carries everything the frontend needs (cookbook + owner display name, no PII).
Referral Attribution on Shared Cookbooks
*Goal*: when a non-customer lands on a shared cookbook, signs up, and places a first order, the sharer earns standard referral credit. Reuse the existing referral rails --- do not build a parallel attribution system.
Existing Rails (verified)
- ~Customer.referral_code~ --- 8-char unique, generated via ~utils.generate_short_hash(8)~ in ~app/services/api/pricing.py:59~. Doubles as a ~PromoCode~ (~type=REFERRAL_DISCOUNT~, ~customer=referrer~).
- ~/r/<code>~ redirector (~app/views/web.py:388-407~) stamps ~request.session["promo"]~ + ~referral_code~ cookie.
- ~PromoMiddleware~ (~app/middleware.py:178-212~) reads session/~?promo=~ and redeems via ~api.redeem_promo_code()~ before first purchase.
- ~award_referral_bonus()~ (~app/services/api/pricing.py:350-376~) issues credit on referee's first order, idempotent via ~CustomerLog~ (~ref3="Referral"~).
Plan
Include the owner's referral code in the shared-cookbook API response; let the frontend stamp it.
1. ~CookbookSharedDetailView~ response adds ~owner_referral_code: str | None~.
- Lazily ensure the owner has a code: if ~cookbook.owner.referral_code~ is empty, call the existing reset/generate helper in ~app/services/api/pricing.py~ (same one used at signup). Avoids drift and keeps ~PromoCode~ side-effects consistent. Do *not* copy the generation logic.
- Populate only when ~visibility ∈ {unlisted, public}~ and ~cookbook.owner_id~ is set (admin cookbooks → ~null~).
2. Frontend, on loading ~/c/<token>~, fires the same cookie-stamping flow ~/r/<code>~ already triggers --- easiest is a hidden fetch to ~/r/<code>~ so ~redirector()~ runs unchanged. (Or a future minimal JSON endpoint that only sets the session/cookie. Frontend choice; flagged here but out of scope.)
3. *Self-referral guard*: if the visitor is already authenticated as the cookbook owner, omit ~owner_referral_code~ from the response. Belt-and-suspenders even though ~award_referral_bonus()~ and ~redeem_promo_code()~ already guard against self-credit --- easier to never stamp the cookie at all.
4. No new credit logic. ~award_referral_bonus()~ fires unchanged when the referee places their first order.
Files Affected
- ~cookbooks/serializers.py~ --- ~CookbookSharedSerializer~ with ~owner_referral_code~.
- ~cookbooks/views.py~ --- ~CookbookSharedDetailView~ ensures owner has a referral code before serializing.
Tests
In ~cookbooks/tests/test_api.py~:
- Shared response for an unlisted cookbook includes ~owner_referral_code~ matching ~cookbook.owner.referral_code~.
- Shared response triggers lazy generation when owner had no code; subsequent calls reuse it.
- Owner viewing their own shared cookbook (authenticated as owner) gets ~owner_referral_code=None~.
- Public list / admin upsert responses do NOT include ~owner_referral_code~ or ~share_token~.
- End-to-end-ish: simulate cookie + first-order via existing test helpers and assert ~award_referral_bonus()~ issues credit (if helpers exist; otherwise rely on the existing referral integration tests + unit-test the new serializer field).
Open Question
Should ~public~ cookbooks carry the referral code, or only ~unlisted~ direct-share ones? Default here: yes for both --- every share is creditable. Easy to flip later.
Files to Modify
- ~cookbooks/models.py~ --- add ~CookbookVisibility~, ~owner~, ~visibility~, ~share_token~ on ~Cookbook~.
- ~cookbooks/migrations/00XX_cookbook_share.py~ --- new.
- ~cookbooks/share.py~ --- new (token gen/assign/rotate).
- ~cookbooks/serializers.py~ --- ~share_token~ method field with owner guard; new ~CookbookSharedSerializer~ exposing ~owner_referral_code~.
- ~cookbooks/views.py~ --- ~CookbookSharedDetailView~, ~CookbookRotateShareTokenView~, list filter update.
- ~cookbooks/urls_v3.py~ --- two new routes.
- ~cookbooks/tests/test_api.py~ --- extend with share-token + referral tests.
Verification
Run inside ~docker compose -f docker-compose.ci.yml run --rm django-test bash -c "..."~:
1. Migration: ~./manage.py migrate cookbooks~; verify existing rows get ~visibility=PUBLIC~.
2. API tests (~cookbooks/tests/test_api.py~):
- Token generation unique on retry.
- ~GET /api/v3/cookbooks/shared/<token>/~: 200 for ~unlisted~ + ~public~, 404 for ~private~, 404 for unknown token.
- ~POST .../share-token/rotate/~: 401 anon, 403 non-owner, 200 owner; old token then 404s.
- ~CookbookSerializer~: ~share_token~ present for owner, absent/null for everyone else.
- List endpoint excludes non-public cookbooks for anon.
- Referral tests per section above.
3. Serial run to dodge parallel-pickle issues (per prior-session note):
#+BEGIN_SRC sh
./manage.py test cookbooks.tests.test_api --parallel 1 -v 2
#+END_SRC
4. Manual: hit ~/api/v3/cookbooks/shared/<token>/~ with ~curl~ for each visibility state; confirm ~owner_referral_code~ presence/absence matches the rules.
Future / Out of Scope
- Username-namespaced vanity URL (~/c/<owner>/<slug>~) for ~public~ cookbooks --- additive, doesn't conflict with token route.
- Audit log / metrics on token rotations.
- Rate-limit rotate endpoint.
- Owner CRUD endpoints for user-created cookbooks (create/update/list-mine/delete).