- ID
- 2ca65cda-6b5d-45fd-ae57-9f7b9bd5c084
Customer-Created Cookbook Create/Update (User Cookbooks)
Context
Customers should be able to create and edit their own personal cookbooks (a
pairing/product collection, like a playlist) through the API. Today
=CookbookType.USER= is a defined-but-unimplemented enum value
(=cookbooks/models.py=) — no view, service, or serializer does anything with
it.
This is explicitly separate from =PUT /api/v2/cookbooks/=
(=CookbookUpsertView=, =cookbooks/views.py:236-426=), which is internal-only
(=IsStaffOrParfaitClient=) and exists for Hungryroot staff / the Parfait tool
to upsert curated marketing cookbooks. That endpoint is untouched by this
work.
Coordination with the sharing spec
exp-242-shareable-cookbooks-tdd.org (companion:
[[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1606746116/Exp+242+-+Favorites+User+Cookbooks][Exp 242 - Favorites + User Cookbooks]]) already plans a =Cookbook.owner= FK,
a =visibility= expansion to =private/unlisted/public=, and a =share_token=
column for link-sharing, and explicitly scopes *this* effort out of its own
doc: "Owner CRUD endpoints for user-created cookbooks (list-mine, create,
update visibility, delete) — they belong to the user-cookbook scaffolding
effort and are wired separately." That doc also notes the =owner= migration
should land in exactly one PR, whichever effort ships first.
*Decision: this TDD ships that migration* (owner + visibility + share_token),
since neither exists in code yet and this effort needs =owner= regardless.
The sharing spec's share-token issuance/rotation and shared-detail endpoint
remain entirely out of scope here — =share_token= is added to the schema but
left unpopulated by this work.
Data Model
Migration (single migration in =cookbooks/migrations/=)
#+begin_src python
class CookbookVisibility(TextChoices):
PRIVATE = "private", "Private"
UNLISTED = "unlisted", "Unlisted"
PUBLIC = "public", "Public"
class Cookbook(BaseModelV1):
# ... existing fields ...
owner = models.ForeignKey(
"app.Customer", null=True, blank=True,
on_delete=models.SET_NULL, related_name="cookbooks",
)
share_token = models.CharField(max_length=16, unique=True, null=True, blank=True, db_index=True)
#+end_src
=visibility= is an =AlterField= on the existing column (choices change only;
=max_length=10= already fits all three new values, no length change needed).
Data backfill (same migration, =RunPython=)
Order matters — backfill =owner= before remapping =visibility=, since the
visibility mapping branches on whether =owner= is set:
1. For every =Cookbook= where =author_id= is set, resolve
=author.user.customer= (skip on =ObjectDoesNotExist= — most real editorial
authors have =author.user = None=) and set =owner= to the result. This
picks up existing Favorites cookbooks, whose =author= is already a
per-customer =CookbookAuthor= row (see =FavoritesService.get_or_create_favorites_cookbook=,
=cookbooks/services.py:276-305=).
2. ~UPDATE cookbook SET visibility = 'public' WHERE owner_id IS NULL~ —
existing admin/marketing cookbooks keep their current public-browsing
behavior regardless of their old =private=/=shared= value (matches the
sharing spec's own backfill rule).
3. ~UPDATE cookbook SET visibility = 'unlisted' WHERE owner_id IS NOT NULL AND visibility = 'shared'~
— existing Favorites cookbooks (the only rows that use =shared= today)
move to =unlisted=: reachable directly, not globally browsable. Behavior
is unchanged in practice, since =CookbookListView= already excludes
=cookbook_type=FAVORITES= regardless of visibility.
**Pre-migration check:** none needed beyond the standard review — this is
additive columns plus a value remap on a single existing enum, not a
uniqueness constraint.
Required coordinated changes to existing code
Removing =CookbookVisibility.SHARED= breaks two existing call sites unless
updated in the same PR:
- =cookbooks/services.py:121= (=CookbookService.upsert_cookbook=, default
visibility for a newly-created marketing cookbook when the Parfait payload
omits it) — change =SHARED= → =PUBLIC=. Confirmed safe: none of the
Parfait-facing write serializers (=CookbookWriteSerializer=,
=CookbookPayloadWriteSerializer=) expose a =visibility= field today, so
=input_dto.visibility= is always =None= in practice and this branch is the
only one actually exercised.
- =cookbooks/services.py:297,301-302,427= (=FavoritesService=) — change
=SHARED= → =UNLISTED=, and set =owner=customer= alongside the existing
=author=author= when creating/repairing the Favorites cookbook, so new
Favorites rows carry =owner= going forward without relying on the
migration backfill.
No changes to =FavoritesService='s author-based lookup queries
(=author__user=customer.user=) — out of scope to also switch those to
=owner=; not needed for this effort and a larger blast radius than
necessary.
Service Layer — =cookbooks/services.py=
Add a small dataclass and two methods on the existing =CookbookService=
(line 97), reusing its private section/item helpers (=_replace_sections=,
=_create_items=, =CookbookSectionUpsertInput=, =CookbookItemUpsertInput=)
rather than duplicating them:
#+begin_src python
@dataclass
class UserCookbookInput:
name: str
description: str | None = None
hero_image: str | None = None
visibility: models.CookbookVisibility = models.CookbookVisibility.PRIVATE
sections: list[CookbookSectionUpsertInput] = field(default_factory=list)
#+end_src
- =create_user_cookbook(self, customer, input_dto)= — inside
=transaction.atomic()=: create a =Cookbook(owner=customer,
cookbook_type=USER, author=None, is_active=True, ...)=, call
=_replace_sections=, return it. No =CookbookAuthor= involved — =owner= is
the sole ownership anchor for =USER= cookbooks; =author= stays reserved for
editorial personas.
- =update_user_cookbook(self, cookbook, input_dto)= — given an
already-ownership-verified =Cookbook= instance, update fields, call
=_replace_sections= inside =transaction.atomic()=, return it.
Not reusing =upsert_cookbook=: it requires an =id=/=slug= on input, resolves
author by slug/id/social_handle, and handles dietary tags/=is_featured= —
none of which apply here. A focused method is simpler than bending it to a
customer path.
Serializer — =cookbooks/serializers.py=
One new serializer, reusing existing validated building blocks:
#+begin_src python
class CustomerUserCookbookWriteSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(required=False, allow_blank=True, allow_null=True)
visibility = serializers.ChoiceField(
choices=[models.CookbookVisibility.PRIVATE, models.CookbookVisibility.UNLISTED],
required=False, default=models.CookbookVisibility.PRIVATE,
)
sections = CookbookSectionWriteSerializer(many=True, required=False, default=list)
#+end_src
=visibility= choices are deliberately restricted to =private=/=unlisted= —
=public= stays admin/marketing-only. This is what keeps customer cookbooks
out of the editorial browse feed without needing to touch the sharing spec's
still-pending =CookbookListView= visibility filter (see Out of Scope).
Reuses =CookbookSectionWriteSerializer=/=CookbookItemWriteSerializer=
(existing pairing/product existence + item_type validation),
=_validate_unique_section_content=, and
=_auto_select_hero_image_from_sections= — no new section/item validation
logic needed. Excludes =id=, =slug=, =hero_image= (auto-derived only),
=is_featured=, =is_active=, =sort_order=, =cookbook_type=, =author=, and
dietary tags — all server-controlled or excluded per product decision.
Views — =cookbooks/views.py=
Two new classes, after =CustomerFavoritesView= (after line 831), following
its exact shape (=CustomerModelMixin=, =permission_classes = [IsStaff |
IsCustomerPathAuthorized]=):
- =CustomerUserCookbookListView= — =POST= only. Validates via the new
serializer, calls =CookbookService().create_user_cookbook(self.customer,
...)=, returns =CookbookDetailSerializer= data with =201=.
- =CustomerUserCookbookDetailView= — =PUT= only. Looks up the target via
=Cookbook.objects.get(pk=pk, cookbook_type=models.CookbookType.USER,
owner=self.customer)=, 404ing on =Cookbook.DoesNotExist= — this is what
keeps the synthetic Favorites cookbook, marketing cookbooks, and other
customers' cookbooks unreachable through this endpoint. Then validates and
calls =update_user_cookbook=, returns =CookbookDetailSerializer= data.
Staff callers (=IsStaff=) can act on any customer via the =customer_id= path
segment, matching =CustomerFavoritesView='s existing behavior.
One required change to existing =CookbookListView=
=cookbooks/views.py:582-589= (=CookbookListView.get_queryset=) currently
excludes =visibility=PRIVATE= and =cookbook_type=FAVORITES=. Add
=.exclude(cookbook_type=models.CookbookType.USER)= so customer-created
cookbooks — even =unlisted= ones — never mix into the public marketing
browse feed. This closes the leak this effort introduces; it does not
attempt the sharing spec's separate =visibility=public=-only filter for
anonymous/non-owner callers on that same view (still pending, tracked in
that spec).
URLs — =cookbooks/urls_v3.py=
#+begin_src python
.register_view("customers/<str:customer_id>/cookbooks/", CustomerUserCookbookListView.as_view(), name="v3-customer-cookbook-list")
.register_view("customers/<str:customer_id>/cookbooks/<int:pk>/", CustomerUserCookbookDetailView.as_view(), name="v3-customer-cookbook-detail")
#+end_src
Registered alongside the existing =customers/<str:customer_id>/cookbooks/favorites/=
route. =<int:pk>= avoids any dispatch ambiguity with the public
=cookbooks/<slug:slug>/= route.
Test Plan (TDD — write these first)
New file =cookbooks/tests/test_user_cookbook_view.py=, modeled on
=cookbooks/tests/test_favorites_view.py= (=BaseAPITestCase=,
=factories.CustomerFactory=, =route_name= class attr, =reverse()=,
=force_authenticate= in =setUp=).
**=CustomerUserCookbookCreateTestCase= (POST):**
1. =test_requires_authentication= — 403 unauthenticated
2. =test_wrong_customer_path_forbidden= — 403 when path =customer_id= !=
authenticated user
3. =test_staff_can_create_for_any_customer= — 201
4. =test_creates_cookbook_with_expected_defaults= — minimal payload →
=cookbook_type == "user"=, =visibility == "private"=, =owner_id= matches
customer, sqid slug present
5. =test_creates_cookbook_with_sections_and_items= — pairing + product item →
response =pairing_ids=/=product_ids= correct, hero_image auto-derived
6. =test_accepts_unlisted_visibility= and =test_self_customer_id=
7. =test_rejects_public_visibility= — =public= is not in the customer-facing
choices → 400 (explicit regression test for the admin-only restriction)
8. =test_validation_errors= (subTest over): missing =name=, invalid
=visibility=, duplicate section position, duplicate pairing in section,
nonexistent pairing/product id, item_type/id mismatch
9. =test_customer_can_create_multiple_cookbooks= — two POSTs → two rows,
same =owner=, different pks
**=CustomerUserCookbookUpdateTestCase= (PUT):**
1. =test_requires_authentication=, =test_wrong_customer_path_forbidden=
2. =test_updates_name_description_visibility_and_sections= — full replace,
verify old section gone
3. **=test_cannot_edit_favorites_cookbook=** — create via
=FavoritesService.get_or_create_favorites_cookbook=, PUT its pk under the
owning customer → =404= (explicit regression test — the Favorites
cookbook must never be editable here)
4. =test_cannot_edit_marketing_cookbook= — existing staff-created marketing
cookbook pk → =404=
5. =test_cannot_edit_another_customers_cookbook= — =other_customer='s USER
cookbook, PUT via =customer='s path → =404=
6. =test_staff_can_update_any_customers_cookbook= — 200
7. =test_validation_errors= — same subTest set as create
**Service-level** (=cookbooks/tests/test_services.py=, =BaseTestCase=, no
HTTP):
- =test_create_user_cookbook_sets_type_and_owner=
- =test_update_user_cookbook_replaces_sections=
**Regression coverage for the migration/coordination changes** (existing
files):
- =cookbooks/tests/test_favorites_service.py= — update any assertion on
=cookbook.visibility == CookbookVisibility.SHARED= to =UNLISTED=; add
=test_favorites_cookbook_has_owner_set=
- =cookbooks/tests/test_api.py= (=CookbookV2WriteAPITestCase=) — confirm a
newly-created marketing cookbook via Parfait upsert still defaults to
=PUBLIC= visibility when omitted
- =cookbooks/tests/test_views.py= (=CookbookListViewFavoritesExclusionTestCase=
or a new sibling) — add a case confirming a =USER= cookbook is excluded
from the public list even when =visibility=unlisted=
Run scope: the new test files/classes above, plus the existing Favorites and
=CookbookV2WriteAPITestCase= suites (touched by the coordinated
=services.py=/=models.py= changes).
Out of Scope
- =GET= endpoints for listing or retrieving a customer's own cookbooks —
not requested; create/update responses return the full
=CookbookDetailSerializer= payload so the client has everything without a
follow-up request. A private/unlisted cookbook is otherwise unreachable
after creation until a list-mine or retrieve-by-owner endpoint exists —
flagging as a real gap for whoever picks up the read side.
- =PATCH= (partial update) — =PUT= is full-replace only, matching the
existing Parfait upsert endpoint's semantics.
- Everything in exp-242-shareable-cookbooks-tdd.org: =share_token= issuance/rotation,
the =GET /api/v3/cookbooks/shared/<token>/= endpoint, and the
=visibility=public=-only tightening of =CookbookListView= for
anonymous/non-owner callers.
- Dietary tags on customer cookbooks.
- =referral_code= on =CustomerUserCookbookListView=/=CustomerUserCookbookDetailView=
responses. Favorites exposes =customer.referral_code= on its =GET= response
(~cookbooks/services.py:364~, Mapping-backed =FavoritesEnvelope=), but
=get_referral_code()= (~cookbooks/serializers.py:258-263~) only returns a
value for =Mapping= objects — real =Cookbook= ORM instances (what
=create_user_cookbook=/=update_user_cookbook= return) always serialize
=referral_code: null=. Note Favorites' own =POST= response doesn't include
it either, only its =GET= does — so this isn't a regression, just an
undecided gap. Revisit once =CookbookSharedDetailView= (this doc's
companion spec) lands and there's an actual share surface to attach a
referral code to.
Verification
1. ~python manage.py test cookbooks.tests.test_user_cookbook_view --keepdb~
2. ~python manage.py test cookbooks.tests.test_services --keepdb~
3. ~python manage.py test cookbooks.tests.test_favorites_service cookbooks.tests.test_api cookbooks.tests.test_views --keepdb~ (regression on coordinated changes)
4. ~python manage.py makemigrations cookbooks --check~ to confirm the migration is generated/applied cleanly
5. Manual smoke test via =runserver=: POST a cookbook as a test customer,
confirm =201= + sqid slug + =owner_id=; PUT an update, confirm sections
replace; attempt PUT on a Favorites/Marketing cookbook id and confirm
=404=
6. =prek run ruff-format --files <modified files>= and =prek run ruff --files <modified files>=
Proposed Ticket Breakdown
Sequenced so each ticket is independently mergeable and TDD-complete (tests
land with the code that needs them, not deferred to a final ticket).
#+ATTR_HTML: :border 1
| # | Scope | Key Files | Depends On |
|---+-----------------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+------------|
| 1 | Data model migration: =owner=, =share_token=, =visibility= expansion + backfill; coordinated =SHARED= → =PUBLIC=/=UNLISTED= fixes in =upsert_cookbook= and =FavoritesService= | =cookbooks/models.py=, =cookbooks/migrations/=, =cookbooks/services.py:121,297,301-302,427=, =cookbooks/tests/test_favorites_service.py=, =cookbooks/tests/test_api.py= (regression) | none |
| 2 | Service layer + serializer: =UserCookbookInput= dataclass, =create_user_cookbook=, =update_user_cookbook=, =CustomerUserCookbookWriteSerializer= | =cookbooks/services.py=, =cookbooks/serializers.py=, =cookbooks/tests/test_services.py= | 1 |
| 3 | Views + URLs: =CustomerUserCookbookListView= (POST), =CustomerUserCookbookDetailView= (PUT), =CookbookListView= USER-type exclusion, route registration | =cookbooks/views.py=, =cookbooks/urls_v3.py=, =cookbooks/tests/test_user_cookbook_view.py=, =cookbooks/tests/test_views.py= (regression) | 2 |
Tickets 2-3 could collapse into a single ticket if preferred — the whole
effort is small enough for one PR (per the doc's own migration-coordination
note); this split is for estimation/review-size purposes, not a hard
requirement to ship separately.