- 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=]]