diff --git a/README.md b/README.md index 3c4db35..7fa6bf4 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,20 @@ Each is also exposed as a Celery task (`api.`) and triggerable from the ad - `python manage.py seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`. - `python manage.py backup_db` (`--full` for media too): Creates a consistent SQLite snapshot in `backend/backups/`. +#### Categories & `recategorize_flavors` + +`sync_flavors` derives a flavor's category from the Shopify `product_type` (e.g. `42 - Syrup Bottle` → **Syrup**), stripping the numeric prefix and the form-factor word. Any new drink line Holy ships **auto-creates** its category on the next sync — no code change needed. Packs, shakers, merch, stickers and other non-drink types funnel to **Packs and other**. + +One catch: `sync_flavors` only assigns a category when it **creates** a row. Existing flavors keep their original category, so a categorization change won't retro-apply to rows already in the DB. Use the one-off command below to fix mis-shelved rows. It is run manually (not exposed as a Celery task / Jobs-tab entry) and is **dry-run by default** — nothing is written without `--apply`. + +```bash +python manage.py recategorize_flavors # dry-run: report what would move +python manage.py recategorize_flavors --apply # persist the moves +python manage.py recategorize_flavors --apply --merge-collisions # also fold name-clashes +``` + +It re-fetches the catalog, matches rows by `external_id`, and re-runs the exact `sync_flavors` categorization logic. Rows not in the catalog (discontinued) are never touched. When a move would collide with an existing flavor of the same name in the target category (`UniqueConstraint(name, category)`), it is skipped and reported unless `--merge-collisions` is passed, which folds the rows via `merge_flavors` (preserving ratings and replies). + --- ## 💾 Backups & Restoration diff --git a/backend/api/management/commands/recategorize_flavors.py b/backend/api/management/commands/recategorize_flavors.py new file mode 100644 index 0000000..6fea07d --- /dev/null +++ b/backend/api/management/commands/recategorize_flavors.py @@ -0,0 +1,143 @@ +import requests +from django.core.management.base import BaseCommand +from django.db import transaction + +from api.management.commands.sync_flavors import Command as SyncCommand +from api.models import Category, Flavor +from api.services.flavor import merge_flavors + +_CATALOG_URL = "https://weareholy.com/products.json?limit=250" +_PACKS_SLUG = "packs-and-other" +_PACKS_NAME = "Packs and other" + + +class Command(BaseCommand): + help = ( + "Re-evaluate the category of every already-synced flavor against the current " + "sync_flavors logic and move mis-shelved rows. Dry-run unless --apply is given. " + "Existing flavors keep their creation-time category during normal sync, so this " + "is the way to retro-apply categorization changes to rows that already exist." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--apply", + action="store_true", + help="Persist changes. Without this flag the command only reports what would change.", + ) + parser.add_argument( + "--merge-collisions", + action="store_true", + help=( + "If the target category already holds a flavor with the same name, merge " + "this row into it (preserves ratings) instead of skipping. Implies a write; " + "only acts under --apply." + ), + ) + + def fetch_products(self) -> list[dict]: + response = requests.get(_CATALOG_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) + response.raise_for_status() + return response.json().get("products", []) + + def handle(self, *args, **options): + apply = options["apply"] + merge_collisions = options["merge_collisions"] + + try: + products = self.fetch_products() + except Exception as e: + self.stdout.write(self.style.ERROR(f"Failed to fetch catalog: {e}")) + return + + by_id = {p["id"]: p for p in products if p.get("id") is not None} + self.stdout.write(f"Fetched {len(by_id)} catalog products.") + + sync = SyncCommand() + moved = merged = collisions = unresolved = not_in_catalog = unchanged = 0 + + # Everything mutating runs in one transaction. resolve_category auto-creates + # categories as a side effect, so dry-run rolls the whole thing back to stay pure. + with transaction.atomic(): + packs_category = Category.objects.get_or_create( + slug=_PACKS_SLUG, defaults={"name": _PACKS_NAME} + )[0] + moved, merged, collisions, unresolved, not_in_catalog, unchanged = self._process( + by_id, sync, packs_category, apply, merge_collisions + ) + if not apply: + transaction.set_rollback(True) + + verb = "Applied" if apply else "Would change (dry-run)" + self.stdout.write( + self.style.SUCCESS( + f"{verb}: moved={moved}, merged={merged}, collisions={collisions}, " + f"unresolved={unresolved}, unchanged={unchanged}, not_in_catalog={not_in_catalog}" + ) + ) + if not apply and (moved or collisions): + self.stdout.write("Re-run with --apply to persist.") + + def _process(self, by_id, sync, packs_category, apply, merge_collisions): + moved = merged = collisions = unresolved = not_in_catalog = unchanged = 0 + + for flavor in Flavor.objects.filter(external_id__isnull=False).select_related("category"): + product = by_id.get(flavor.external_id) + if product is None: + not_in_catalog += 1 + continue + + title = product.get("title", "").strip() + tags = product.get("tags", []) + if isinstance(tags, str): + tags = [t.strip() for t in tags.split(",")] + tags_lower = [t.lower() for t in tags] + + target = sync.resolve_category(product, title.lower(), tags_lower, packs_category) + + if target is None: + unresolved += 1 + self.stdout.write( + self.style.WARNING( + f" ? {flavor.name!r} [{flavor.category.slug}]: resolver yields no " + f"category (product_type={product.get('product_type')!r}) — left as-is" + ) + ) + continue + + if target.id == flavor.category_id: + unchanged += 1 + continue + + conflict = ( + Flavor.objects.filter(name=flavor.name, category=target) + .exclude(pk=flavor.pk) + .first() + ) + if conflict is not None: + collisions += 1 + if merge_collisions: + self.stdout.write( + f" MERGE {flavor.name!r}: {flavor.category.slug} -> {target.slug} " + f"(into existing row #{conflict.pk})" + ) + if apply: + merge_flavors(conflict, flavor) + merged += 1 + else: + self.stdout.write( + self.style.WARNING( + f" SKIP {flavor.name!r}: target {target.slug} already has a flavor " + f"named {flavor.name!r} (#{conflict.pk}). Re-run with " + f"--merge-collisions to fold them." + ) + ) + continue + + self.stdout.write(f" MOVE {flavor.name!r}: {flavor.category.slug} -> {target.slug}") + if apply: + flavor.category = target + flavor.save(update_fields=["category"]) + moved += 1 + + return moved, merged, collisions, unresolved, not_in_catalog, unchanged diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py index bff8879..5062559 100644 --- a/backend/api/management/commands/sync_flavors.py +++ b/backend/api/management/commands/sync_flavors.py @@ -7,11 +7,93 @@ from urllib.parse import urlparse import requests from django.conf import settings from django.core.management.base import BaseCommand +from django.utils.text import slugify from api.models import Category, Flavor _DOWNLOAD_WORKERS = 8 +# Shopify product_type looks like "42 - Syrup Bottle" / "01 - Energy Bundle". +# Strip the numeric prefix, then the trailing form-factor word, to get the +# drink family ("Syrup", "Energy", "Iced Tea") that becomes a Category. +_PRODUCT_TYPE_PREFIX = re.compile(r"^\s*\d+\s*[-–—]\s*") + +# Form-factor words are Holy's internal SKU taxonomy, NOT a pack signal: +# "Energy Bundle" is a single can, "Caipirinha Crab" is its title. Pack +# detection stays title-driven (see _is_pack). These only get stripped to +# reveal the family. +_FORM_WORDS = frozenset( + {"bundle", "sachet", "sachetbox", "bottle", "box", "pack", "set", "sachets"} +) + +# When the product_type family itself is one of these, it's not a drink line — +# funnel to "Packs and other" instead of minting a category for it. +_NON_FLAVOR_TOKENS = frozenset( + { + "value", + "shaker", + "merch", + "sticker", + "packaging", + "material", + "other", + "items", + "mixed", + "collector", + "collector's", + "free", + "products", + "naturalrabatt", + "sample", + "samplebox", + } +) + +_PACKS_SLUG = "packs-and-other" +_PACKS_NAME = "Packs and other" + +_PACK_TITLE_KEYWORDS = ( + "bundle", + "set", + "box", + "probe", + "sample", + "taster", + "starter", + "collection", + "probier", +) + +# Fallback only — product_type covers these, but some rows lack one. +_TAG_FAMILY = ( + (("holy energy", "energy"), "Energy"), + (("holy hydration", "hydration"), "Hydration"), + (("holy iced tea", "iced tea"), "Iced Tea"), + (("milkshake",), "Milkshake"), +) + + +def _family_from_product_type(product_type: str) -> str | None: + """Return the drink family from a Shopify product_type, or None. + + "42 - Syrup Bottle" -> "Syrup"; "Value Pack" / "Shaker" -> None (non-flavor). + """ + if not product_type: + return None + stripped = _PRODUCT_TYPE_PREFIX.sub("", product_type).strip() + if not stripped: + return None + + tokens = stripped.split() + if any(tok.lower() in _NON_FLAVOR_TOKENS for tok in tokens): + return None + + while tokens and tokens[-1].lower() in _FORM_WORDS: + tokens.pop() + if not tokens: + return None + return " ".join(tokens) + class Command(BaseCommand): help = "Syncs flavors from Holy Energy Shopify API" @@ -80,6 +162,40 @@ class Command(BaseCommand): results[i] = saved return [results[i] for i, _, _ in targets if i in results] + @staticmethod + def _category_for_family(family_name: str) -> Category: + """get_or_create a Category from a family name. slugify('Iced Tea') == 'iced-tea', + which matches the pre-existing slugs, so known families are reused not duplicated.""" + slug = slugify(family_name) + return Category.objects.get_or_create(slug=slug, defaults={"name": family_name})[0] + + @staticmethod + def _is_pack(title_lower: str, tags_lower: list[str]) -> bool: + if any(k in title_lower for k in _PACK_TITLE_KEYWORDS): + return True + return "shaker" in title_lower or "merch" in tags_lower + + 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. + """ + if self._is_pack(title_lower, tags_lower): + return packs_category + + family = _family_from_product_type(product.get("product_type", "")) + if family: + return self._category_for_family(family) + + for keywords, name in _TAG_FAMILY: + if any(k in tags_lower for k in keywords): + return self._category_for_family(name) + + if "shaker" in tags_lower: + return packs_category + return None + def handle(self, *args, **options): url = "https://weareholy.com/products.json?limit=250" self.stdout.write(f"Fetching from {url}...") @@ -95,21 +211,9 @@ class Command(BaseCommand): products = data.get("products", []) self.stdout.write(f"Found {len(products)} products.") - cat_map = { - "energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0], - "hydration": Category.objects.get_or_create( - name="Hydration", defaults={"slug": "hydration"} - )[0], - "iced-tea": Category.objects.get_or_create( - name="Iced Tea", defaults={"slug": "iced-tea"} - )[0], - "milkshake": Category.objects.get_or_create( - name="Milkshake", defaults={"slug": "milkshake"} - )[0], - "packs": Category.objects.get_or_create( - name="Packs and other", defaults={"slug": "packs-and-other"} - )[0], - } + packs_category = Category.objects.get_or_create( + slug=_PACKS_SLUG, defaults={"name": _PACKS_NAME} + )[0] created_count = 0 updated_count = 0 @@ -124,40 +228,9 @@ class Command(BaseCommand): tags_lower = [t.lower() for t in tags] title_lower = title.lower() - is_pack = False - pack_keywords = [ - "bundle", - "set", - "box", - "probe", - "sample", - "taster", - "starter", - "collection", - "probier", - ] - if any(k in title_lower for k in pack_keywords): - is_pack = True - if "shaker" in title_lower or "merch" in tags_lower: - is_pack = True - - category = None - if is_pack: - category = cat_map["packs"] - elif "holy energy" in tags_lower or "energy" in tags_lower: - category = cat_map["energy"] - elif "holy hydration" in tags_lower or "hydration" in tags_lower: - category = cat_map["hydration"] - elif "holy iced tea" in tags_lower or "iced tea" in tags_lower: - category = cat_map["iced-tea"] - elif "milkshake" in tags_lower: - category = cat_map["milkshake"] - + category = self.resolve_category(p, title_lower, tags_lower, packs_category) if not category: - if "shaker" in tags_lower: - category = cat_map["packs"] - else: - continue + continue variants = p.get("variants", []) is_available = any(v.get("available") for v in variants) diff --git a/backend/api/tests/test_recategorize.py b/backend/api/tests/test_recategorize.py new file mode 100644 index 0000000..2347e9f --- /dev/null +++ b/backend/api/tests/test_recategorize.py @@ -0,0 +1,69 @@ +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase + +from api.models import Category, Flavor, Rating, User + + +def _product(pid, title, product_type="", tags=None): + return {"id": pid, "title": title, "product_type": product_type, "tags": tags or []} + + +class RecategorizeTests(TestCase): + def setUp(self) -> None: + self.packs = Category.objects.create(name="Packs and other", slug="packs-and-other") + self.energy = Category.objects.create(name="Energy", slug="energy") + + def _run(self, products, **opts): + with patch( + "api.management.commands.recategorize_flavors.Command.fetch_products", + return_value=products, + ): + call_command("recategorize_flavors", **opts) + + 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.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.refresh_from_db() + self.assertEqual(flavor.category.slug, "syrup") + + 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) + flavor.refresh_from_db() + self.assertEqual(flavor.category_id, self.energy.id) + + def test_row_not_in_catalog_is_skipped(self) -> None: + flavor = Flavor.objects.create(name="Gone", category=self.packs, external_id=999) + self._run([_product(1, "Mojito Macaw", "01 - Energy Bundle")], apply=True) + flavor.refresh_from_db() + self.assertEqual(flavor.category_id, self.packs.id) + + def test_collision_skipped_without_flag(self) -> None: + syrup = Category.objects.create(name="Syrup", slug="syrup") + # Target already holds a row of the same name. + Flavor.objects.create(name="Dup", category=syrup, external_id=100) + moving = Flavor.objects.create(name="Dup", category=self.packs, external_id=42) + self._run([_product(42, "Dup", "42 - Syrup Bottle")], apply=True) + moving.refresh_from_db() + self.assertEqual(moving.category_id, self.packs.id) # untouched + self.assertEqual(Flavor.objects.filter(name="Dup").count(), 2) + + def test_collision_merges_with_flag(self) -> None: + user = User.objects.create_user(username="u", password="p") + syrup = Category.objects.create(name="Syrup", slug="syrup") + keep = Flavor.objects.create(name="Dup", category=syrup, external_id=100) + moving = Flavor.objects.create(name="Dup", category=self.packs, external_id=42) + Rating.objects.create(user=user, flavor=moving, score=5) + self._run([_product(42, "Dup", "42 - Syrup Bottle")], apply=True, merge_collisions=True) + self.assertFalse(Flavor.objects.filter(pk=moving.pk).exists()) + self.assertTrue(Rating.objects.filter(flavor=keep, user=user).exists()) diff --git a/backend/api/tests/test_sync_categories.py b/backend/api/tests/test_sync_categories.py new file mode 100644 index 0000000..63fdeee --- /dev/null +++ b/backend/api/tests/test_sync_categories.py @@ -0,0 +1,54 @@ +from django.test import TestCase + +from api.management.commands.sync_flavors import Command, _family_from_product_type +from api.models import Category + + +class FamilyFromProductTypeTests(TestCase): + def test_strips_prefix_and_form_word(self) -> None: + self.assertEqual(_family_from_product_type("42 - Syrup Bottle"), "Syrup") + self.assertEqual(_family_from_product_type("01 - Energy Bundle"), "Energy") + self.assertEqual(_family_from_product_type("04 - Energy Sachet"), "Energy") + + def test_multiword_family(self) -> None: + self.assertEqual(_family_from_product_type("02 - Iced Tea Bundle"), "Iced Tea") + + def test_non_flavor_families_return_none(self) -> None: + for pt in ("10 - Shaker", "11 - Merch", "12 - Value Pack", "16 - Packaging Material"): + self.assertIsNone(_family_from_product_type(pt), pt) + + def test_empty_or_missing(self) -> None: + self.assertIsNone(_family_from_product_type("")) + self.assertIsNone(_family_from_product_type("99 - ")) + + +class ResolveCategoryTests(TestCase): + def setUp(self) -> None: + self.cmd = Command() + self.packs = Category.objects.create(name="Packs and other", slug="packs-and-other") + + def _resolve(self, product, title="", tags=None): + tags = tags or [] + 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") + self.assertIsNotNone(cat) + self.assertEqual(cat.slug, "syrup") + self.assertTrue(Category.objects.filter(slug="syrup").exists()) + + 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") + self.assertEqual(cat.id, existing.id) + + def test_pack_title_keyword_wins_over_family(self) -> None: + cat = self._resolve({"product_type": "43 - Syrup Bundle"}, title="3er Box Syrup") + self.assertEqual(cat.id, self.packs.id) + + def test_tag_fallback_when_no_product_type(self) -> None: + cat = self._resolve({}, title="Mojito Macaw", tags=["holy energy"]) + self.assertEqual(cat.slug, "energy") + + def test_no_signal_returns_none(self) -> None: + self.assertIsNone(self._resolve({"product_type": "20 - Other Items"}, title="Mystery"))