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
+401
-48
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user