Org Web Adapter

hungryroot/brand/first-class-brand-model-plan.org

ID
8581092b-735c-4325-9534-c458ba287756

Plan: first-class =Brand= model

Plan: first-class =Brand= model

CUSTOM_ID
plan-first-class-brand-model

This document outlines a phased approach to introduce a canonical

=Brand= entity (image, description, relationships) while migrating away

from the denormalized =Product.brand_name= string field.

Current state (baseline)

CUSTOM_ID
current-state-baseline

- *=Product.brand_name=*: optional =CharField(max_length=50)= --- no

shared metadata, no image, inconsistent spelling across products.

- *Ingredients in recipes*:

[[../../app/models/pairing_ingredient.py][=PairingIngredient=]] points

=ingredient= at *=Product=*. There is no separate =Ingredient= model.

Any relationship between =Brand= and “ingredients” is therefore the

same as the relationship between =Brand= and *products* unless a

future ingredient model is introduced.

Goals

CUSTOM_ID
goals

1. *=Brand= as a first-class model* with stable identity (slug), display

name, marketing copy, and visual asset(s).

2. *Many-to-many with products* so a product can be associated with one

or more brands (see

[[#cardinality-and-modeling-notes][Cardinality]]).

3. *Ingredients*: satisfy the requirement by documenting that

ingredients are =Product= instances; the same M2M applies. Optionally

add explicit APIs or admin affordances that filter “products that

appear as pairing ingredients” if product/ingredient roles need to

differ in UX.

4. *Backward-compatible migration* from =brand_name= text where

possible.

Proposed =Brand= model (sketch)

CUSTOM_ID
proposed-brand-model-sketch

Fields (names indicative; align with existing patterns for images,

slugs, and soft flags):

| Field | Purpose |

|---------------------------+--------------------------------------------------------------------------------------------------------------------------------------|

| =name= | Display name (e.g. “Banza”). |

| =slug= | Unique, URL-safe identifier. |

| =description= | Rich or plain text; match patterns used elsewhere (=HTMLField= vs =TextField=) after reviewing similar entities. |

| =image= / image URL field | Brand logo or hero; follow how =Product= stores image path fields + CDN URL properties, or reuse a shared abstraction if one exists. |

| =is_active= | Hide incomplete or deprecated brands from customer-facing surfaces. |

| =sort_order= | Optional; for ordered brand directories or carousels. |

Standard =BaseModelV1=/audit fields as consistent with sibling models.

Cardinality and modeling notes

CUSTOM_ID
cardinality-and-modeling-notes

- *Product ↔ Brand M2M* matches the stated requirement. In practice,

most SKUs belong to exactly one brand; enforce or prefer that in

UI/validation if business rules require it (e.g., =clean()= on

=Product=, or a =through= model with =is_primary=).

- *Single brand vs many*: If the business only ever allows one brand per

product, consider =ForeignKey= from =Product= to =Brand= for simpler

queries and integrity, and expose a read-only “brands” collection in

API as a one-element list for compatibility. The plan below assumes

true M2M as requested; adjust after product confirms cardinality.

=through= model (recommended)

CUSTOM_ID
through-model-recommended

A *=ProductBrand=* (or =BrandProduct=) through table can hold:

- =product=, =brand=

- =is_primary= (bool) --- which brand to show in compact list views

- Optional =relation_type= enum if later you need “manufacturer” vs

“sub-brand”

This avoids ambiguous =ManyToMany= without ordering or primary flag.

Data migration strategy

CUSTOM_ID
data-migration-strategy

1. *Create* =Brand= table and M2M (or FK + optional M2M) *without*

removing =Product.brand_name=.

2. *Backfill*:

- Distinct, normalized =Product.brand_name= values → =Brand= rows

(slug from slugified name; resolve collisions with suffixes or

manual review queue).

- For each product, attach the =Brand= matching its current

=brand_name=; unmatched rows go to a report for ops.

3. *Dual-write period* (optional but safer): when ops change brand on a

product, update both M2M and =brand_name= via admin/services until

clients rely on =Brand=.

4. *Read path switch*: serializers and search indexes prefer =Brand=

data (name, image, description) with fallback to =brand_name= if no

link.

5. *Deprecate* =brand_name=:

- Stop writing in UIs/automation; eventually make nullable/remove per

standard migration policy

([[../../.cursor/rules/database-orm.mdc][migration safety rules]]:

do not drop production tables without DBA process; field removal

may be a follow-up migration once all callers use =Brand=).

Search and discovery

CUSTOM_ID
search-and-discovery

- *Algolia / OpenSearch*: Today product records include =brand_name=

text. Plan to sync *=brand.name=* (and optionally slug) onto each

indexed product document so search/ranking behavior stays at least as

good as today.

- *REST list filters*: Today there is no =brand= filter on

[[../../app/rest/product.py][=ProductFilter=]]. Add filter(s) such as

=brand= / =brands= (CSV of IDs or slugs) once the model exists.

API surface (high level)

CUSTOM_ID
api-surface-high-level

- *Brand read endpoints* (public or authenticated, per existing

patterns): list/detail by id or slug; include image URL(s),

description, linked product counts if useful.

- *Product serializers*: embed =brands= (minimal: id, name, slug, image

URL) or a single =primary_brand= if using a through-model flag.

- *Ingredient payloads* that currently expose =ingredient.brand_name=

should expose the same brand object off the related =Product=.

Version any public contract changes (=/api/v3/products=, mobile clients)

per deprecation policy.

Django admin and ops

CUSTOM_ID
django-admin-and-ops

- =BrandAdmin=: manage image, description, merge duplicates, deactivate.

- =ProductAdmin=: edit M2M (or autocomplete FK) instead of free-text

=brand_name= once stable.

- Bulk tools: extend patterns from bulk product update to set

=brand_id=(s) consistently.

Testing

CUSTOM_ID
testing

- Model tests: M2M attach/detach, =is_primary= uniqueness per product

(if enforced).

- API tests: filter by brand, serializer shape, inactive brand

exclusion.

- Migration/backfill: dry-run or fixture-based test that distinct

=brand_name= values become expected =Brand= rows.

Risks and open questions

CUSTOM_ID
risks-and-open-questions

1. *Cardinality*: Confirm with product whether M2M is required or a

single FK suffices.

2. *Ingredient as separate entity*: If the org later introduces a

non-=Product= ingredient table, add =Brand= relations there; today,

*no extra M2M to “ingredients”* is needed beyond =Product=.

3. *Client deprecation*: Coordinate iOS/Android/web before removing

=brand_name= from responses

([[../../.cursor/rules/experiment-cleanup.mdc][client deprecation

safety]]).

4. *Image storage*: Reuse existing CDN/image field conventions; avoid

one-off URL strings unless consistent with =Product=.

Suggested implementation phases

CUSTOM_ID
suggested-implementation-phases

1. *Phase 1 --- Model + admin*: =Brand=, through model, read-only APIs,

backfill management command.

2. *Phase 2 --- Product integration*: serializers, filters,

Algolia/OpenSearch fields, keep =brand_name= in sync or read-fallback

only.

3. *Phase 3 --- Deprecation*: remove writes to =brand_name=; schedule

field removal after adoption metrics.

Appendix: related code references

CUSTOM_ID
appendix-related-code-references

- =Product.brand_name=:

[[../../app/models/product.py][=app/models/product.py=]]

- Product list filters / search:

[[../../app/rest/product.py][=app/rest/product.py=]]

- Recipe ingredients → =Product=:

[[../../app/models/pairing_ingredient.py][=app/models/pairing_ingredient.py=]]

- Algolia product index includes =brand_name=:

[[../../app/index.py][=app/index.py=]]