Org Web Adapter

hungryroot/popsicle/customer-cookbook-crud.org

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.