- ID
- 2ca65cda-6b5d-45fd-ae57-9f7b9bd5c084
Customer-Created Cookbook Create/Update (User Cookbooks)
TDD for two new customer-facing API endpoints that let a customer list,
view, create, edit, and delete their own personal cookbook (a
pairing/product collection, like a playlist), wiring up the
previously-unused =CookbookType.USER= enum value.
Context
Customers should be able to create, edit, and delete their own personal
cookbooks 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.customer= 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 spec also notes the =customer= migration
should land in exactly one PR, whichever effort ships first.
*Decision:* this TDD ships that migration (customer + visibility + share_token),
since neither exists in code yet and this effort needs =customer= 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 ...
customer = 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)
deleted_at = models.DateTimeField(null=True, blank=True)
#+end_src
=visibility= is an =AlterField= on the existing column (choices change only;
=max_length=10= already fits all three new values).
Data backfill (same migration, =RunPython=)
Order matters — backfill =customer= before remapping =visibility=, since the
visibility mapping branches on whether =customer= is set:
1. For every =Cookbook= where =author_id= is set, skip rows where
=author.user_id= is =None= (most real editorial authors have no linked
user), then resolve =author.user.customer= (skip on
=ObjectDoesNotExist= — the linked user has no =Customer=) and set =customer=
to the result. Two distinct skip conditions, checked separately:
=CookbookAuthor.user= is nullable, so testing =user_id is None= first
avoids an =AttributeError= from chaining =.customer= off =None= before the
=try= is ever reached. This picks up existing Favorites cookbooks, whose
=author= is already a per-customer =CookbookAuthor= row
(=FavoritesService.get_or_create_favorites_cookbook=,
=cookbooks/services.py:276-305=).
2. ~UPDATE cookbook SET visibility = 'public' WHERE customer_id IS NULL AND visibility = 'shared'~
— existing admin/marketing cookbooks that were browseable as =shared=
keep that public-browsing behavior. Scoped to =visibility = 'shared'=
specifically (not every customerless row) so a customerless cookbook that was
deliberately =private= stays =private= — the remap only touches the value
being removed, it never promotes a row that was intentionally hidden.
3. ~UPDATE cookbook SET visibility = 'unlisted' WHERE customer_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.
=deleted_at= needs no backfill — it's a plain =AddField= with =null=True=, so
every existing row starts non-deleted (=deleted_at IS NULL=).
**Pre-migration check:** none needed beyond 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 =customer=customer= alongside the existing
=author=author= when creating/repairing the Favorites cookbook, so new
Favorites rows carry =customer= 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
=customer=; not needed for this effort and a larger blast radius than
necessary.
Soft delete (=Cookbook.deleted_at=)
A customer-created cookbook is soft-deleted, not hard-deleted: a nullable
=deleted_at= timestamp records when the delete happened (=NULL= means not
deleted), rather than repurposing =is_active= (already used for
marketing-cookbook publish state, and filtered on by =CookbookListView= for
that purpose) or a plain boolean flag — this deviates from the existing
=Address.is_deleted= convention (=app/models/address.py:29=) deliberately,
since knowing *when* a cookbook was deleted is useful for future
audit/cleanup tooling and costs nothing extra to capture at write time. No
cascade to =CookbookSection=/=CookbookItem= is needed — every read path
gates through the parent =Cookbook='s =deleted_at= flag, so its children
simply become unreachable alongside it.
Every query that resolves a =Cookbook= for a customer-facing read or write
must filter =deleted_at__isnull=True= (or
=.exclude(deleted_at__isnull=False)=) going forward — see the Views and
Service Layer sections below.
Service Layer — =cookbooks/services.py=
Add a small dataclass and three 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(customer=customer,
cookbook_type=USER, author=None, is_active=True, ...)=, call
=_replace_sections=, return it. No =CookbookAuthor= involved — =customer= 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.
- =delete_user_cookbook(self, cookbook)= — given an
already-ownership-verified =Cookbook= instance, set
=deleted_at=timezone.now()= and save (=update_fields=["deleted_at"]=). No
=transaction.atomic()= needed — it's a single-field update with no related
writes.
- =list_user_cookbooks(self, customer)= — returns
=Cookbook.objects.filter(customer=customer, cookbook_type=USER,
deleted_at__isnull=True).order_by("-create_date")=. No pagination — a
customer's own cookbook count is expected to stay small; revisit if that
assumption breaks.
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.
=sections= is the only mechanism for attaching items to a cookbook — there is
no separate top-level =items= shortcut, matching the existing Parfait
=CookbookWriteSerializer='s shape. It's optional on both the create and
update payload (=required=False, default=list=), same for every field but
=name=. For a flat cookbook with no meaningful named groupings, clients send
a single section with =name=""= and =position=0= — =CookbookSectionWriteSerializer.name=
already defaults to =""= when blank/omitted, so no new field or validation is
needed to support this. On =PUT=, omitting =sections= behaves the same as
sending =sections: []= — it clears all existing sections, consistent with
=PUT= being a full replace (see the Example Requests section below).
Views — =cookbooks/views.py=
Two new classes, after =CustomerFavoritesView= (after line 831), following
its exact shape (=CustomerModelMixin=, =permission_classes = [IsStaff |
IsCustomerPathAuthorized]=):
- =CustomerUserCookbookListView= — =GET= and =POST=. =GET= calls
=CookbookService().list_user_cookbooks(self.customer)= and returns a bare
list of =CookbookDetailSerializer= data (no pagination envelope), newest
first. =POST= validates via the new serializer, calls
=CookbookService().create_user_cookbook(self.customer, ...)=, returns
=CookbookDetailSerializer= data with =201=.
- =CustomerUserCookbookDetailView= — =GET=, =PUT=, and =DELETE=. All three
share one lookup: =Cookbook.objects.get(pk=pk,
cookbook_type=models.CookbookType.USER, customer=self.customer,
deleted_at__isnull=True)=, 404ing on =Cookbook.DoesNotExist= — this is what keeps
the synthetic Favorites cookbook, marketing cookbooks, other customers'
cookbooks, and already-deleted cookbooks unreachable through this
endpoint. =GET= serializes the looked-up cookbook with
=CookbookDetailSerializer= and returns =200= — no new service method
needed, since retrieval is just the existing ownership-verified lookup
with no additional business logic. =PUT= validates and calls
=update_user_cookbook=, returns =CookbookDetailSerializer= data. =DELETE=
calls =CookbookService().delete_user_cookbook(cookbook)= and returns =204=
with no body.
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, and =.exclude(deleted_at__isnull=False)= so a soft-deleted
cookbook never appears there either. 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. The detail route handles =GET=, =PUT=, and
=DELETE= on the same URL — no separate routes needed for retrieve or delete.
Example Requests
Illustrative only — field values (=id=s, =slug=, hero image URL) are
representative, not literal fixture data. Every payload requires =name=
plus *exactly one* of =sections= or =items= — providing both, or neither,
is a =400=.
GET /api/v3/customers/4207/cookbooks/
No request body. Returns a bare list (no pagination envelope) of the
customer's own non-deleted =user= cookbooks, newest first, each with the
full =CookbookDetailSerializer= payload:
#+begin_src json
[
{
"id": 4821,
"slug": "aB3xQ9",
"name": "Weeknight Dinners",
"description": "Quick meals for busy weeknights.",
"hero_image": "https://cdn.hungryroot.com/pairings/125736/hero.jpg",
"is_featured": false,
"is_active": true,
"sort_order": 0,
"cookbook_type": "user",
"visibility": "private",
"kind": null,
"customer_id": 4207,
"author": null,
"author_id": null,
"dietary_tag_ids": [],
"collection_ids": [],
"pairing_count": 1,
"product_count": 1,
"referral_code": null,
"pairing_ids": [125736],
"product_ids": [991],
"section_ids": [9931],
"sections": [
{
"id": 9931,
"name": "Mains",
"position": 0,
"items": [
{"id": "pairing-125736", "item_type": "pairing", "position": 0, "pairing_id": 125736, "product_id": null},
{"id": "product-991", "item_type": "product", "position": 1, "pairing_id": null, "product_id": 991}
]
}
]
}
]
#+end_src
An empty result is =[]=, not a =404= — the endpoint always resolves for an
authorized caller regardless of whether the customer has created any
cookbooks yet.
POST /api/v3/customers/4207/cookbooks/
Request:
#+begin_src json
{
"name": "Weeknight Dinners",
"description": "Quick meals for busy weeknights.",
"visibility": "private",
"sections": [
{
"name": "Mains",
"position": 0,
"items": [
{"item_type": "pairing", "pairing_id": 125736, "position": 0},
{"item_type": "product", "product_id": 991, "position": 1}
]
}
]
}
#+end_src
Every field but =name= is optional — ={"name": "Weeknight Dinners"}= alone is
a valid payload and creates a cookbook with no sections.
Response =201 Created= (=CookbookDetailSerializer=):
#+begin_src json
{
"id": 4821,
"slug": "aB3xQ9",
"name": "Weeknight Dinners",
"description": "Quick meals for busy weeknights.",
"hero_image": "https://cdn.hungryroot.com/pairings/125736/hero.jpg",
"is_featured": false,
"is_active": true,
"sort_order": 0,
"cookbook_type": "user",
"visibility": "private",
"kind": null,
"customer_id": 4207,
"author": null,
"author_id": null,
"dietary_tag_ids": [],
"collection_ids": [],
"pairing_count": 1,
"product_count": 1,
"referral_code": null,
"pairing_ids": [125736],
"product_ids": [991],
"section_ids": [9931],
"sections": [
{
"id": 9931,
"name": "Mains",
"position": 0,
"items": [
{"id": "pairing-125736", "item_type": "pairing", "position": 0, "pairing_id": 125736, "product_id": null},
{"id": "product-991", "item_type": "product", "position": 1, "pairing_id": null, "product_id": 991}
]
}
]
}
#+end_src
POST /api/v3/customers/4207/cookbooks/ — flat cookbook, no named sections
For a simple flat cookbook where named groupings don't matter, send a single
section with a blank =name= and =position: 0=:
Request:
#+begin_src json
{
"name": "Quick Bites",
"sections": [
{
"name": "",
"position": 0,
"items": [
{"item_type": "pairing", "pairing_id": 125736, "position": 0},
{"item_type": "product", "product_id": 991, "position": 1}
]
}
]
}
#+end_src
Response =201 Created= (=CookbookDetailSerializer=) — same shape as any
other cookbook, just with an unnamed section:
#+begin_src json
{
"id": 4822,
"slug": "cD7yR2",
"name": "Quick Bites",
"description": null,
"hero_image": "https://cdn.hungryroot.com/pairings/125736/hero.jpg",
"is_featured": false,
"is_active": true,
"sort_order": 0,
"cookbook_type": "user",
"visibility": "private",
"kind": null,
"customer_id": 4207,
"author": null,
"author_id": null,
"dietary_tag_ids": [],
"collection_ids": [],
"pairing_count": 1,
"product_count": 1,
"referral_code": null,
"pairing_ids": [125736],
"product_ids": [991],
"section_ids": [9933],
"sections": [
{
"id": 9933,
"name": "",
"position": 0,
"items": [
{"id": "pairing-125736", "item_type": "pairing", "position": 0, "pairing_id": 125736, "product_id": null},
{"id": "product-991", "item_type": "product", "position": 1, "pairing_id": null, "product_id": 991}
]
}
]
}
#+end_src
GET /api/v3/customers/4207/cookbooks/4821/
No request body. Same lookup and =404= behavior as =PUT=/=DELETE= below —
unreachable for the Favorites cookbook, marketing cookbooks, another
customer's cookbook, or an already-deleted cookbook. Response =200 OK=
(=CookbookDetailSerializer=) is identical in shape to the =POST=/=PUT=
responses; see those examples for the full payload.
PUT /api/v3/customers/4207/cookbooks/4821/
Full replace of the cookbook created in the first example above — renames
it, switches =visibility= to =unlisted=, and swaps the single =Mains=
section for a =Mains=/=Sides= split:
Request:
#+begin_src json
{
"name": "Weeknight Dinners (Updated)",
"visibility": "unlisted",
"sections": [
{
"name": "Mains",
"position": 0,
"items": [
{"item_type": "pairing", "pairing_id": 125736, "position": 0}
]
},
{
"name": "Sides",
"position": 1,
"items": [
{"item_type": "product", "product_id": 991, "position": 0}
]
}
]
}
#+end_src
Response =200 OK= (=CookbookDetailSerializer=):
#+begin_src json
{
"id": 4821,
"slug": "aB3xQ9",
"name": "Weeknight Dinners (Updated)",
"description": "Quick meals for busy weeknights.",
"hero_image": "https://cdn.hungryroot.com/pairings/125736/hero.jpg",
"is_featured": false,
"is_active": true,
"sort_order": 0,
"cookbook_type": "user",
"visibility": "unlisted",
"kind": null,
"customer_id": 4207,
"author": null,
"author_id": null,
"dietary_tag_ids": [],
"collection_ids": [],
"pairing_count": 1,
"product_count": 1,
"referral_code": null,
"pairing_ids": [125736],
"product_ids": [991],
"section_ids": [9931, 9932],
"sections": [
{
"id": 9931,
"name": "Mains",
"position": 0,
"items": [
{"id": "pairing-125736", "item_type": "pairing", "position": 0, "pairing_id": 125736, "product_id": null}
]
},
{
"id": 9932,
"name": "Sides",
"position": 1,
"items": [
{"id": "product-991", "item_type": "product", "position": 0, "pairing_id": null, "product_id": 991}
]
}
]
}
#+end_src
=description= was omitted from this =PUT= body and carries over unchanged —
every field except =sections= is a normal optional-on-omit field. Note the
asymmetry: omitting =description= leaves it as-is, while omitting =sections=
clears it, since =sections= alone carries a =default=list= (see the
Serializer section above). Had this request also omitted =sections=, the
response would show ="sections": []=, ="pairing_ids": []=,
="product_ids": []=.
Gherkin Scenarios
BDD-style scenarios, grouped by endpoint, describing expected behavior for
each of the customer-facing cookbook endpoints.
#+begin_src gherkin
Feature: List a customer's personal cookbooks
As an authenticated customer
I want to see all my personal cookbooks
So that I can find and manage the ones I've created
Background:
Given a customer "owner" with id 4207
Scenario: Unauthenticated request is rejected
Given no authenticated user
When a client sends GET to "/api/v3/customers/4207/cookbooks/"
Then the response status is 403
Scenario: Customer cannot list under another customer's path
Given "owner" is the authenticated customer
When "owner" sends GET to "/api/v3/customers/9999/cookbooks/"
Then the response status is 403
Scenario: Staff can list any customer's cookbooks
Given a staff user is authenticated
When the staff user sends GET to "/api/v3/customers/4207/cookbooks/"
Then the response status is 200
Scenario: Empty list when the customer has no personal cookbooks
Given "owner" is the authenticated customer
And "owner" has no "user" cookbooks
When "owner" sends GET to "/api/v3/customers/4207/cookbooks/"
Then the response status is 200
And the response is an empty list
Scenario: List returns the customer's own cookbooks newest first
Given "owner" is the authenticated customer
And "owner" owns two "user" cookbooks, "Weeknight Dinners" created before "Quick Bites"
When "owner" sends GET to "/api/v3/customers/4207/cookbooks/"
Then the response status is 200
And the response is a list of 2 cookbooks
And the first cookbook's "name" is "Quick Bites"
And the second cookbook's "name" is "Weeknight Dinners"
Scenario: List excludes cookbooks that don't belong to the caller
Given "owner" has a Favorites cookbook created via FavoritesService.get_or_create_favorites_cookbook
And a staff-created marketing cookbook exists
And "other_customer" owns a "user" cookbook
And "owner" owns a soft-deleted "user" cookbook
When "owner" sends GET to "/api/v3/customers/4207/cookbooks/"
Then none of those cookbooks appear in the response
Scenario: Same customer id in the path as "self" is accepted
Given "owner" is the authenticated customer
When "owner" sends GET to "/api/v3/customers/self/cookbooks/"
Then the response status is 200
Feature: Create a personal cookbook
As an authenticated customer
I want to create my own personal cookbook
So that I can group pairings and products like a playlist
Background:
Given a customer "owner" with id 4207
Scenario: Unauthenticated request is rejected
Given no authenticated user
When a client sends POST to "/api/v3/customers/4207/cookbooks/"
Then the response status is 403
Scenario: Customer cannot create under another customer's path
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/9999/cookbooks/"
Then the response status is 403
Scenario: Staff can create a cookbook for any customer
Given a staff user is authenticated
When the staff user sends POST to "/api/v3/customers/4207/cookbooks/" with a valid payload
Then the response status is 201
Scenario: Minimal payload creates a cookbook with expected defaults
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/4207/cookbooks/" with only "name"
Then the response status is 201
And the response "cookbook_type" is "user"
And the response "visibility" is "private"
And the response "customer_id" is 4207
And the response "slug" is a non-empty sqid
Scenario: Payload with sections and items returns derived ids and hero image
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/4207/cookbooks/" with one section containing a pairing and a product
Then the response "pairing_ids" contains the submitted pairing id
And the response "product_ids" contains the submitted product id
And the response "hero_image" is auto-derived from the first item
Scenario: Flat cookbook uses a single section with a blank name
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/4207/cookbooks/" with one section where "name" is "" and "position" is 0
Then the response status is 201
And the response's first section "name" is ""
Scenario Outline: Visibility choice on create
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/4207/cookbooks/" with "visibility" set to "<visibility>"
Then the response status is <status>
Examples:
| visibility | status |
| private | 201 |
| unlisted | 201 |
| public | 400 |
Scenario: Same customer id in the path as "self" is accepted
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/self/cookbooks/" with a valid payload
Then the response status is 201
Scenario Outline: Validation errors on create
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/4207/cookbooks/" with <invalid_condition>
Then the response status is 400
Examples:
| invalid_condition |
| no "name" |
| an invalid "visibility" value |
| two sections sharing the same "position" |
| the same pairing id twice in one section |
| a nonexistent pairing or product id |
| an item whose "item_type" doesn't match its id field |
Scenario: Customer can create multiple cookbooks
Given "owner" is the authenticated customer
When "owner" sends POST to "/api/v3/customers/4207/cookbooks/" twice with different names
Then two distinct cookbooks exist
And both have "customer_id" 4207
Feature: Retrieve a personal cookbook
As an authenticated customer
I want to fetch a single personal cookbook by id
So that I can view its current state directly
Background:
Given a customer "owner" with id 4207
And "owner" owns a "user" cookbook with id 4821
Scenario: Unauthenticated request is rejected
Given no authenticated user
When a client sends GET to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 403
Scenario: Customer cannot retrieve under another customer's path
Given "owner" is the authenticated customer
When "owner" sends GET to "/api/v3/customers/9999/cookbooks/4821/"
Then the response status is 403
Scenario: Owner can retrieve their own cookbook
Given "owner" is the authenticated customer
When "owner" sends GET to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 200
And the response "id" is 4821
Scenario: Staff can retrieve any customer's cookbook
Given a staff user is authenticated
When the staff user sends GET to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 200
Scenario Outline: Cannot retrieve a protected or foreign cookbook
Given <protected_cookbook>
When "owner" sends GET to that cookbook's detail URL
Then the response status is 404
Examples:
| protected_cookbook |
| "owner"'s Favorites cookbook |
| a staff-created marketing cookbook |
| "other_customer"'s "user" cookbook |
| "owner"'s already-deleted "user" cookbook |
Feature: Update a personal cookbook
As an authenticated customer
I want to fully replace my own cookbook's fields and sections
So that I can keep it current without losing ownership guarantees
Background:
Given a customer "owner" with id 4207
And "owner" owns a "user" cookbook with id 4821 and one section "Mains"
Scenario: Unauthenticated request is rejected
Given no authenticated user
When a client sends PUT to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 403
Scenario: Customer cannot update under another customer's path
Given "owner" is the authenticated customer
When "owner" sends PUT to "/api/v3/customers/9999/cookbooks/4821/"
Then the response status is 403
Scenario: Full replace updates name, description, visibility, and sections
Given "owner" is the authenticated customer
When "owner" sends PUT to "/api/v3/customers/4207/cookbooks/4821/" with a new name, description, "visibility" of "unlisted", and a "Sides" section replacing "Mains"
Then the response status is 200
And the response "sections" no longer contains "Mains"
And the response "sections" contains "Sides"
Scenario: Cannot edit the synthesized Favorites cookbook
Given "owner" has a Favorites cookbook created via FavoritesService.get_or_create_favorites_cookbook
When "owner" sends PUT to that Favorites cookbook's detail URL
Then the response status is 404
Scenario: Cannot edit a marketing cookbook
Given a staff-created marketing cookbook exists
When "owner" sends PUT to that marketing cookbook's detail URL
Then the response status is 404
Scenario: Cannot edit another customer's cookbook
Given "other_customer" owns a "user" cookbook with id 5001
When "owner" sends PUT to "/api/v3/customers/4207/cookbooks/5001/"
Then the response status is 404
Scenario: Staff can update any customer's cookbook
Given a staff user is authenticated
When the staff user sends PUT to "/api/v3/customers/4207/cookbooks/4821/" with a valid payload
Then the response status is 200
Scenario Outline: Validation errors on update
Given "owner" is the authenticated customer
When "owner" sends PUT to "/api/v3/customers/4207/cookbooks/4821/" with <invalid_condition>
Then the response status is 400
Examples:
| invalid_condition |
| no "name" |
| an invalid "visibility" value |
| two sections sharing the same "position" |
| the same pairing id twice in one section |
| a nonexistent pairing or product id |
| an item whose "item_type" doesn't match its id field |
Feature: Delete a personal cookbook
As an authenticated customer
I want to remove my own cookbook
So that it stops appearing to me, without losing the underlying record
Background:
Given a customer "owner" with id 4207
And "owner" owns a "user" cookbook with id 4821
Scenario: Unauthenticated request is rejected
Given no authenticated user
When a client sends DELETE to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 403
Scenario: Customer cannot delete under another customer's path
Given "owner" is the authenticated customer
When "owner" sends DELETE to "/api/v3/customers/9999/cookbooks/4821/"
Then the response status is 403
Scenario: Delete soft-deletes rather than removing the row
Given "owner" is the authenticated customer
When "owner" sends DELETE to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 204
And cookbook 4821 still exists in the database
And cookbook 4821's "deleted_at" is set
Scenario: Deleted cookbook is unreachable afterward
Given cookbook 4821 has already been deleted
When "owner" sends GET or PUT to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 404
Scenario Outline: Cannot delete a protected cookbook
Given <protected_cookbook>
When "owner" sends DELETE to that cookbook's detail URL
Then the response status is 404
Examples:
| protected_cookbook |
| "owner"'s Favorites cookbook |
| a staff-created marketing cookbook |
| "other_customer"'s "user" cookbook |
Scenario: Staff can delete any customer's cookbook
Given a staff user is authenticated
When the staff user sends DELETE to "/api/v3/customers/4207/cookbooks/4821/"
Then the response status is 204
Feature: Coordinated visibility and feed regression behavior
As the platform
I want the visibility migration and public browse feed to stay consistent
So that existing Favorites/marketing cookbooks and the new USER type never leak
Scenario: Existing Favorites cookbook gains a customer and unlisted visibility
Given a Favorites cookbook created before this migration with "visibility" of "shared"
When the backfill migration runs
Then the cookbook's "customer_id" is set to the favoriting customer
And the cookbook's "visibility" becomes "unlisted"
Scenario: Customerless shared cookbook becomes public, private stays private
Given a customerless marketing cookbook with "visibility" of "shared"
And a separate customerless marketing cookbook with "visibility" of "private"
When the backfill migration runs
Then the first cookbook's "visibility" becomes "public"
And the second cookbook's "visibility" remains "private"
Scenario: Marketing cookbook created via Parfait upsert still defaults to public visibility
Given a Parfait upsert payload that omits "visibility"
When the marketing cookbook is created via CookbookService.upsert_cookbook
Then the cookbook's "visibility" is "public"
Scenario: Public cookbook list excludes USER-type cookbooks even when unlisted
Given a "user" cookbook with "visibility" of "unlisted"
When an anonymous client requests the public cookbook list
Then that cookbook does not appear in the results
Scenario: Public cookbook list excludes soft-deleted cookbooks regardless of type
Given a marketing cookbook with "deleted_at" set
When an anonymous client requests the public cookbook list
Then that cookbook does not appear in the results
#+end_src
Out of Scope
- =PATCH= (partial update) — =PUT= is full-replace only, matching the
existing Parfait upsert endpoint's semantics.
- Hard delete, undelete/restore, and any admin tooling to purge soft-deleted
cookbooks — this effort only adds the =deleted_at= field and the
customer-facing =DELETE= action that sets it.
- 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. Note: =CookbookListView= currently excludes
only =PRIVATE=, so =UNLISTED= cookbooks remain publicly browseable until
that tightening lands — same behavior these rows had as =shared= before
this migration, not a regression introduced here, but worth flagging given
=UNLISTED='s name now implies otherwise.
- 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=. 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.
- Abuse prevention on =CustomerUserCookbookListView='s =POST= (and, if it
becomes a problem, the other write actions). Today nothing bounds how many
cookbooks a customer can create — no rate limit, and no uniqueness check
on =name= (per-customer or otherwise), so a customer can call =POST=
unbounded times, including with a duplicate name, and every call succeeds.
Revisit with a scoped =UserRateThrottle= registered in
=DEFAULT_THROTTLE_RATES= (matching the existing
=LoginOtpRequestThrottle=/=PromoAnonRateThrottle= pattern) if velocity
abuse shows up, and/or a hard per-customer cookbook-count cap enforced in
=create_user_cookbook= (via =list_user_cookbooks(customer).count()=) if
unbounded storage/list growth becomes the concern instead.
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=: GET the list-mine endpoint for a fresh
test customer, confirm =200= + =[]=; POST a cookbook, confirm =201= +
sqid slug + =customer_id=; GET the list-mine endpoint again, confirm the new
cookbook appears; GET the detail endpoint for that cookbook's pk, confirm
=200= with the same payload; POST another using a single blank-name
section, confirm it behaves identically to a named section; PUT an
update, confirm sections replace; DELETE it, confirm =204=, then GET/PUT
the same pk and confirm =404=; attempt GET/PUT/DELETE 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: =customer=, =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=, =delete_user_cookbook=, =CustomerUserCookbookWriteSerializer=, =Cookbook.deleted_at= field + migration | =cookbooks/models.py=, =cookbooks/migrations/=, =cookbooks/services.py=, =cookbooks/serializers.py=, =cookbooks/tests/test_services.py= | 1 |
| 3 | Views + URLs: =CustomerUserCookbookListView= (GET, POST), =CustomerUserCookbookDetailView= (GET, PUT, DELETE), =CookbookListView= USER-type + =deleted_at= 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.