implemented recategorize

This commit is contained in:
nvrl committed 2026-05-28 22:02:52 +02:00
1 parent 75bf251f07
commit bf48e190cd
5 files changed
+401 -48

No files matched your search

+14
View File
@@ -113,6 +113,20 @@ Each is also exposed as a Celery task (`api.<name>`) and triggerable from the ad
- `python manage.py seed_banners`: Updates procedurally generated banner configurations from `backend/banners/*.json`. - `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/`. - `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 ## 💾 Backups & Restoration
@@ -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
+121 -48
View File
@@ -7,11 +7,93 @@ from urllib.parse import urlparse
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.text import slugify
from api.models import Category, Flavor from api.models import Category, Flavor
_DOWNLOAD_WORKERS = 8 _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): class Command(BaseCommand):
help = "Syncs flavors from Holy Energy Shopify API" help = "Syncs flavors from Holy Energy Shopify API"
@@ -80,6 +162,40 @@ class Command(BaseCommand):
results[i] = saved results[i] = saved
return [results[i] for i, _, _ in targets if i in results] 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): def handle(self, *args, **options):
url = "https://weareholy.com/products.json?limit=250" url = "https://weareholy.com/products.json?limit=250"
self.stdout.write(f"Fetching from {url}...") self.stdout.write(f"Fetching from {url}...")
@@ -95,21 +211,9 @@ class Command(BaseCommand):
products = data.get("products", []) products = data.get("products", [])
self.stdout.write(f"Found {len(products)} products.") self.stdout.write(f"Found {len(products)} products.")
cat_map = { packs_category = Category.objects.get_or_create(
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0], slug=_PACKS_SLUG, defaults={"name": _PACKS_NAME}
"hydration": Category.objects.get_or_create( )[0]
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],
}
created_count = 0 created_count = 0
updated_count = 0 updated_count = 0
@@ -124,40 +228,9 @@ class Command(BaseCommand):
tags_lower = [t.lower() for t in tags] tags_lower = [t.lower() for t in tags]
title_lower = title.lower() title_lower = title.lower()
is_pack = False category = self.resolve_category(p, title_lower, tags_lower, packs_category)
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"]
if not category: if not category:
if "shaker" in tags_lower: continue
category = cat_map["packs"]
else:
continue
variants = p.get("variants", []) variants = p.get("variants", [])
is_available = any(v.get("available") for v in variants) is_available = any(v.get("available") for v in variants)
+69
View File
@@ -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())
+54
View File
@@ -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"))