mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
implemented recategorize
This commit is contained in:
1 parent
75bf251f07
commit
bf48e190cd
5 files changed
+400
-47
No files matched your search
@@ -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
|
||||||
@@ -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,39 +228,8 @@ 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:
|
|
||||||
category = cat_map["packs"]
|
|
||||||
else:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
variants = p.get("variants", [])
|
variants = p.get("variants", [])
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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"))
|
||||||
Reference in new issue
Block a user