diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py index 5062559..69cfb6b 100644 --- a/backend/api/management/commands/sync_flavors.py +++ b/backend/api/management/commands/sync_flavors.py @@ -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// - 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 = ( diff --git a/backend/api/tests/test_recategorize.py b/backend/api/tests/test_recategorize.py index 2347e9f..4c39af2 100644 --- a/backend/api/tests/test_recategorize.py +++ b/backend/api/tests/test_recategorize.py @@ -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) diff --git a/backend/api/tests/test_sync_categories.py b/backend/api/tests/test_sync_categories.py index 63fdeee..5913f0f 100644 --- a/backend/api/tests/test_sync_categories.py +++ b/backend/api/tests/test_sync_categories.py @@ -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": "

Syrup desc

", + "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)