- ID
- 6239515d-8f03-48df-9835-8e7e04839335
Cookbook Dietary Tag Backfill
One-off script to populate =Cookbook.dietary_tags= (M2M to =app.Tag=) by unioning,
across every pairing and product referenced from the cookbook's sections:
- the =HAS_X= allergen tags (IDs 66-75), computed from the =has_*= boolean fields, and
- the protein tags (tag groups =MEAT_PROTEIN=, =SEAFOOD_PROTEIN=, =PLANT_PROTEIN=),
sourced directly from each item's existing =tags= M2M.
Tag IDs and exclusion semantics follow the Confluence doc
[[https://hungryroot.atlassian.net/wiki/spaces/WD/pages/1597276176/Discover+Cookbooks+Filter+to+API+Mapping][Discover Cookbooks - Filter to API Mapping]]:
- =/api/v3/cookbooks/= filters protein via =tag_id= against this set
and filters dietary restrictions via =exclude_tag_id= against the =HAS_*= subset
(e.g. Vegan -> =exclude_tag_id=66,67,74,69,72,75=).
- VEGAN/GLUTEN_FREE badge tags are still NOT written here.
Usage
1. SSH to server, =python manage.py shell=.
2. Paste the script below.
3. With =DRY_RUN=True= (default) it prints =existing= / =computed= / =adding= per cookbook
without writing.
4. Optionally set =COOKBOOK_ID= to a single cookbook id to scope the run.
5. When the diff looks right, flip =DRY_RUN=False= and paste again.
Existing tags are preserved; the script only adds missing =HAS_*= tag IDs (merge, not replace).
Script
#SRC_BEGIN python
from django.db import transaction
from cookbooks.models import Cookbook, CookbookDietaryTag, CookbookItem, CookbookItemType
from app.models.tag import Tag
from app.models.tag_group import TagGroup
# Set to a single Cookbook ID to test on one; leave None to process all.
COOKBOOK_ID = None
DRY_RUN = True # Flip to False to actually write.
# has_* field on DietaryMixin -> Tag.ID for the HAS_X allergen tag.
# Mirrors exclude_tag_id sets in the Confluence "Discover Cookbooks - Filter to API Mapping" doc.
HAS_FIELD_TO_TAG_ID = {
"has_meat": Tag.ID.HAS_MEAT, # 66
"has_fish": Tag.ID.HAS_FISH, # 67
"has_gluten": Tag.ID.HAS_GLUTEN, # 68
"has_dairy": Tag.ID.HAS_DAIRY, # 69
"has_soy": Tag.ID.HAS_SOY, # 70
"has_nut": Tag.ID.HAS_NUT, # 71
"has_egg": Tag.ID.HAS_EGG, # 72
"has_peanut": Tag.ID.HAS_PEANUT, # 73
"has_byproduct": Tag.ID.HAS_BYPRODUCT, # 74
"has_shellfish": Tag.ID.HAS_SHELLFISH, # 75
}
PROTEIN_TAG_GROUP_IDS = {
TagGroup.ID.MEAT_PROTEIN, # 1
TagGroup.ID.SEAFOOD_PROTEIN, # 2
TagGroup.ID.PLANT_PROTEIN, # 3
}
def allergen_tag_ids(obj) -> set[int]:
"""Return HAS_X tag IDs for any allergen the obj contains."""
return {tag_id for field, tag_id in HAS_FIELD_TO_TAG_ID.items() if getattr(obj, field)}
def protein_tag_ids(obj) -> set[int]:
"""Return protein Tag IDs already assigned to obj via its tags M2M."""
return set(
obj.tags.filter(tag_group_id__in=PROTEIN_TAG_GROUP_IDS).values_list("id", flat=True)
)
def compute_cookbook_tag_ids(cookbook: Cookbook) -> set[int]:
items = (
CookbookItem.objects
.filter(section__cookbook=cookbook)
.select_related("pairing", "product")
.prefetch_related("pairing__tags", "product__tags")
)
tag_ids: set[int] = set()
for item in items:
obj = item.pairing if item.item_type == CookbookItemType.PAIRING else item.product
if obj is None:
continue
tag_ids |= allergen_tag_ids(obj)
tag_ids |= protein_tag_ids(obj)
return tag_ids
def update_tags(cookbook_id=None, dry_run=True):
qs = Cookbook.objects.all()
if cookbook_id is not None:
qs = qs.filter(id=cookbook_id)
for cookbook in qs:
computed = compute_cookbook_tag_ids(cookbook)
existing = set(cookbook.dietary_tags.values_list("id", flat=True))
to_add = computed - existing
print(
f"Cookbook {cookbook.id} {cookbook.slug!r}: existing={sorted(existing)} "
f"computed={sorted(computed)} adding={sorted(to_add)}"
)
if dry_run:
print("Dry run, no changes made")
continue
if not to_add:
print("No new tags to add, no changes made")
continue
with transaction.atomic():
CookbookDietaryTag.objects.bulk_create(
[CookbookDietaryTag(cookbook=cookbook, tag_id=tid) for tid in to_add],
ignore_conflicts=True,
)
print("done")
#SRC_END