mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
syrup expand
This commit is contained in:
1 parent
fc52834320
commit
0dbd29472f
3 files changed
+244
-55
No files matched your search
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user