syrup expand

This commit is contained in:
nvrl committed 2026-05-28 22:34:29 +02:00
1 parent fc52834320
commit 0dbd29472f
3 files changed
+244 -55

No files matched your search

+147 -49
View File
@@ -64,6 +64,17 @@ _PACK_TITLE_KEYWORDS = (
"probier",
)
# Accessories (cleaning brushes, spare parts) inherit a drink form-factor in
# their product_type ("Reinigungsbürste – Syrup Bottle" -> "42 - Syrup Bottle"),
# so family resolution would mis-shelve them as that drink. The title is the
# only signal. Funnel to "Packs and other" like shakers.
_ACCESSORY_TITLE_KEYWORDS = (
"bürste",
"reinigung",
"ersatzteil",
"zubehör",
)
# Fallback only — product_type covers these, but some rows lack one.
_TAG_FAMILY = (
(("holy energy", "energy"), "Energy"),
@@ -178,12 +189,15 @@ class Command(BaseCommand):
def resolve_category(self, product, title_lower, tags_lower, packs_category) -> Category | None:
"""Pick a Category for a Shopify product. None means skip (no usable signal).
Order: pack detection (title/tags) -> product_type family (dynamic, auto-creates)
-> tag-based family fallback -> shaker tag -> skip.
Order: pack detection (title/tags) -> accessory title -> product_type family
(dynamic, auto-creates) -> tag-based family fallback -> shaker tag -> skip.
"""
if self._is_pack(title_lower, tags_lower):
return packs_category
if any(k in title_lower for k in _ACCESSORY_TITLE_KEYWORDS):
return packs_category
family = _family_from_product_type(product.get("product_type", ""))
if family:
return self._category_for_family(family)
@@ -196,6 +210,118 @@ class Command(BaseCommand):
return packs_category
return None
@staticmethod
def _is_syrup_variant_product(product) -> bool:
"""True for the single 'Syrup' article whose variants ARE the flavors.
Unlike every other line, syrup has no standalone per-flavor products — it
ships as one bundle ('3er Box Syrup') with a 'Flavor' option. Its variants
are expanded into individual Flavor rows (see _sync_syrup_variants).
"""
if _family_from_product_type(product.get("product_type", "")) != "Syrup":
return False
has_flavor_option = any(
(o.get("name") or "").lower() == "flavor" for o in product.get("options", [])
)
return has_flavor_option and len(product.get("variants", [])) > 1
def _upsert_flavor(
self,
*,
external_id,
name,
category,
description,
shop_url,
is_available,
image_url,
image_urls,
dir_slug,
) -> bool:
"""Find-or-create one Flavor row and refresh its fields. Returns True if created.
Match order: external_id -> exact name in category -> case-insensitive name.
"""
flavor = Flavor.objects.filter(external_id=external_id).first()
if not flavor:
flavor = Flavor.objects.filter(name=name, category=category).first()
if not flavor:
flavor = Flavor.objects.filter(name__iexact=name, category=category).first()
created = flavor is None
if flavor is None:
flavor = Flavor(external_id=external_id, name=name, category=category, is_legacy=False)
else:
if not flavor.external_id:
flavor.external_id = external_id
if flavor.is_legacy:
flavor.is_legacy = False
if (
flavor.name != name
and not Flavor.objects.filter(name=name, category=category)
.exclude(pk=flavor.pk)
.exists()
):
flavor.name = name
flavor.description = description
flavor.shop_url = shop_url
flavor.is_available = is_available
local_paths = self.download_gallery(image_urls, dir_slug)
if local_paths:
flavor.local_image_paths = local_paths
# Drop main_image_path if it points to a file no longer in the gallery
if flavor.main_image_path and flavor.main_image_path not in local_paths:
flavor.main_image_path = None
elif not flavor.local_image_paths:
# No new paths and nothing previously stored — leave both empty
flavor.main_image_path = None
flavor.image_url = image_url
flavor.image_urls = image_urls
flavor.save()
return created
def _sync_syrup_variants(self, product, synced_external_ids) -> tuple[int, int]:
"""Expand the syrup bundle's variants into individual Syrup flavors.
Each variant id becomes a Flavor.external_id (BigInt, unique) under the Syrup
category; the variant's featured_image is its per-flavor image. Returns
(created, updated) counts.
"""
category = self._category_for_family("Syrup")
description = self.clean_html(product.get("body_html", ""))
handle = product.get("handle")
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
created = updated = 0
for v in product.get("variants", []):
name = (v.get("option1") or v.get("title") or "").strip()
variant_id = v.get("id")
if not name or variant_id is None:
continue
featured = v.get("featured_image") or {}
image_url = featured.get("src")
image_urls = [image_url] if image_url else []
if self._upsert_flavor(
external_id=variant_id,
name=name,
category=category,
description=description,
shop_url=shop_url,
is_available=bool(v.get("available")),
image_url=image_url,
image_urls=image_urls,
dir_slug=str(variant_id),
):
created += 1
else:
updated += 1
synced_external_ids.append(variant_id)
return created, updated
def handle(self, *args, **options):
url = "https://weareholy.com/products.json?limit=250"
self.stdout.write(f"Fetching from {url}...")
@@ -217,7 +343,7 @@ class Command(BaseCommand):
created_count = 0
updated_count = 0
synced_external_ids = []
synced_external_ids: list[int] = []
for p in products:
title = p.get("title", "").strip()
@@ -228,6 +354,13 @@ class Command(BaseCommand):
tags_lower = [t.lower() for t in tags]
title_lower = title.lower()
# Syrup ships as one article whose variants are the flavors — expand them.
if self._is_syrup_variant_product(p):
c, u = self._sync_syrup_variants(p, synced_external_ids)
created_count += c
updated_count += u
continue
category = self.resolve_category(p, title_lower, tags_lower, packs_category)
if not category:
continue
@@ -241,55 +374,20 @@ class Command(BaseCommand):
handle = p.get("handle")
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
# Find or create - be very specific to avoid duplicates
# 1. Try external_id
flavor = Flavor.objects.filter(external_id=p["id"]).first()
if not flavor:
# 2. Try exact name match in category
flavor = Flavor.objects.filter(name=title, category=category).first()
if not flavor:
# 3. Try case-insensitive name match in category (safety for whitespace/case)
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
if not flavor:
flavor = Flavor(external_id=p["id"], name=title, category=category, is_legacy=False)
if self._upsert_flavor(
external_id=p["id"],
name=title,
category=category,
description=description,
shop_url=shop_url,
is_available=is_available,
image_url=image_url,
image_urls=image_urls_list,
dir_slug=str(p["id"]),
):
created_count += 1
else:
updated_count += 1
if not flavor.external_id:
flavor.external_id = p["id"]
if flavor.is_legacy:
flavor.is_legacy = False
if (
flavor.name != title
and not Flavor.objects.filter(name=title, category=category)
.exclude(pk=flavor.pk)
.exists()
):
flavor.name = title
flavor.description = description
flavor.shop_url = shop_url
flavor.is_available = is_available
# Download whole gallery into flavors/<external_id>/
dir_slug = str(p["id"])
local_paths = self.download_gallery(image_urls_list, dir_slug)
if local_paths:
flavor.local_image_paths = local_paths
# Drop main_image_path if it points to a file no longer in the gallery
if flavor.main_image_path and flavor.main_image_path not in local_paths:
flavor.main_image_path = None
elif not flavor.local_image_paths:
# No new paths and nothing previously stored — leave both empty
flavor.main_image_path = None
flavor.image_url = image_url
flavor.image_urls = image_urls_list
flavor.save()
synced_external_ids.append(p["id"])
discontinued_count = (
+15 -4
View File
@@ -24,18 +24,29 @@ class RecategorizeTests(TestCase):
def test_dry_run_does_not_move(self) -> None:
# Mis-shelved: a real syrup product currently parked in packs.
flavor = Flavor.objects.create(name="Reinigungsbürste", category=self.packs, external_id=42)
self._run([_product(42, "Reinigungsbürste", "42 - Syrup Bottle")])
flavor = Flavor.objects.create(name="Tropical Punch", category=self.packs, external_id=42)
self._run([_product(42, "Tropical Punch", "42 - Syrup Bottle")])
flavor.refresh_from_db()
self.assertEqual(flavor.category_id, self.packs.id)
self.assertFalse(Category.objects.filter(slug="syrup").exists())
def test_apply_moves_and_creates_category(self) -> None:
flavor = Flavor.objects.create(name="Reinigungsbürste", category=self.packs, external_id=42)
self._run([_product(42, "Reinigungsbürste", "42 - Syrup Bottle")], apply=True)
flavor = Flavor.objects.create(name="Tropical Punch", category=self.packs, external_id=42)
self._run([_product(42, "Tropical Punch", "42 - Syrup Bottle")], apply=True)
flavor.refresh_from_db()
self.assertEqual(flavor.category.slug, "syrup")
def test_accessory_moves_out_of_drink_category(self) -> None:
# The original bug: a cleaning brush synced into Syrup. Recategorize
# must shelve it under packs, not leave it as a drink.
syrup = Category.objects.create(name="Syrup", slug="syrup")
flavor = Flavor.objects.create(
name="Reinigungsbürste – Syrup Bottle", category=syrup, external_id=42
)
self._run([_product(42, "Reinigungsbürste – Syrup Bottle", "42 - Syrup Bottle")], apply=True)
flavor.refresh_from_db()
self.assertEqual(flavor.category_id, self.packs.id)
def test_unchanged_row_stays(self) -> None:
flavor = Flavor.objects.create(name="Mojito Macaw", category=self.energy, external_id=1)
self._run([_product(1, "Mojito Macaw", "01 - Energy Bundle")], apply=True)
+82 -2
View File
@@ -1,7 +1,9 @@
from unittest.mock import patch
from django.test import TestCase
from api.management.commands.sync_flavors import Command, _family_from_product_type
from api.models import Category
from api.models import Category, Flavor
class FamilyFromProductTypeTests(TestCase):
@@ -32,11 +34,17 @@ class ResolveCategoryTests(TestCase):
return self.cmd.resolve_category(product, title.lower(), tags, self.packs)
def test_new_family_auto_creates_category(self) -> None:
cat = self._resolve({"product_type": "42 - Syrup Bottle"}, title="Reinigungsbürste")
cat = self._resolve({"product_type": "42 - Syrup Bottle"}, title="Tropical Punch")
self.assertIsNotNone(cat)
self.assertEqual(cat.slug, "syrup")
self.assertTrue(Category.objects.filter(slug="syrup").exists())
def test_accessory_title_routes_to_packs(self) -> None:
# Cleaning brush carries product_type "42 - Syrup Bottle" but is not a drink.
for title in ("Reinigungsbürste – Syrup Bottle", "Ersatzteile – Syrup Bottle", "Zubehör"):
cat = self._resolve({"product_type": "42 - Syrup Bottle"}, title=title)
self.assertEqual(cat.id, self.packs.id, title)
def test_known_family_reuses_existing_slug(self) -> None:
existing = Category.objects.create(name="Iced Tea", slug="iced-tea")
cat = self._resolve({"product_type": "02 - Iced Tea Bundle"}, title="Erdbeere")
@@ -52,3 +60,75 @@ class ResolveCategoryTests(TestCase):
def test_no_signal_returns_none(self) -> None:
self.assertIsNone(self._resolve({"product_type": "20 - Other Items"}, title="Mystery"))
class SyrupVariantTests(TestCase):
def _bundle(self, variants=None):
return {
"product_type": "43 - Syrup Bundle",
"handle": "3er-box-syrup",
"body_html": "<p>Syrup desc</p>",
"options": [{"name": "Flavor"}],
"variants": variants
or [
{
"id": 111,
"option1": "Strawberry",
"available": True,
"featured_image": {"src": "https://cdn/straw.webp"},
},
{
"id": 222,
"option1": "Mango",
"available": False,
"featured_image": {"src": "https://cdn/mango.webp"},
},
],
}
def test_detects_syrup_bundle(self) -> None:
self.assertTrue(Command._is_syrup_variant_product(self._bundle()))
def test_cleaning_brush_not_expanded(self) -> None:
# Same Syrup family, but no Flavor option and a single variant.
brush = {
"product_type": "42 - Syrup Bottle",
"options": [{"name": "Title"}],
"variants": [{"id": 1, "option1": "Default Title"}],
}
self.assertFalse(Command._is_syrup_variant_product(brush))
def test_other_lines_with_flavor_option_not_expanded(self) -> None:
# Energy probe also has a Flavor option, but those flavors are standalone products.
probe = {
"product_type": "04 - Energy Sachet",
"options": [{"name": "Flavor"}],
"variants": [{"id": 1, "option1": "Mojito Macaw"}, {"id": 2, "option1": "Cherry"}],
}
self.assertFalse(Command._is_syrup_variant_product(probe))
def test_expands_variants_into_individual_flavors(self) -> None:
synced: list[int] = []
with patch.object(Command, "download_gallery", return_value=[]):
created, updated = Command()._sync_syrup_variants(self._bundle(), synced)
self.assertEqual((created, updated), (2, 0))
syrup = Category.objects.get(slug="syrup")
self.assertEqual(
set(Flavor.objects.filter(category=syrup).values_list("name", flat=True)),
{"Strawberry", "Mango"},
)
straw = Flavor.objects.get(name="Strawberry")
self.assertEqual(straw.external_id, 111)
self.assertTrue(straw.is_available)
self.assertEqual(straw.image_url, "https://cdn/straw.webp")
self.assertEqual(straw.shop_url, "https://weareholy.com/products/3er-box-syrup")
self.assertFalse(Flavor.objects.get(name="Mango").is_available)
self.assertEqual(synced, [111, 222])
def test_re_sync_updates_not_duplicates(self) -> None:
with patch.object(Command, "download_gallery", return_value=[]):
Command()._sync_syrup_variants(self._bundle(), [])
created, updated = Command()._sync_syrup_variants(self._bundle(), [])
self.assertEqual((created, updated), (0, 2))
self.assertEqual(Flavor.objects.filter(category__slug="syrup").count(), 2)