From c74524be170aa53c26eb6762433f21784e1381a0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Feb 2026 16:28:29 +0100 Subject: [PATCH] fixed duplicate flavors and improved search bar relevance/fuzzy matching --- .../management/commands/cleanup_duplicates.py | 44 +++++++++++++++ .../api/management/commands/sync_flavors.py | 21 ++++--- backend/api/views.py | 56 ++++++++++++++++--- backend/entrypoint.sh | 4 ++ 4 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 backend/api/management/commands/cleanup_duplicates.py diff --git a/backend/api/management/commands/cleanup_duplicates.py b/backend/api/management/commands/cleanup_duplicates.py new file mode 100644 index 0000000..4dc7246 --- /dev/null +++ b/backend/api/management/commands/cleanup_duplicates.py @@ -0,0 +1,44 @@ +from django.core.management.base import BaseCommand +from api.models import Flavor, Rating +from django.db.models import Count + +class Command(BaseCommand): + help = 'Identifies and merges duplicate flavors (same name and category)' + + def handle(self, *args, **options): + # Find duplicates based on name and category + duplicates = Flavor.objects.values('name', 'category').annotate( + name_count=Count('id') + ).filter(name_count__gt=1) + + self.stdout.write(f"Found {duplicates.count()} sets of duplicates.") + + for dup in duplicates: + name = dup['name'] + category_id = dup['category'] + + # Get all flavors in this set, ordered by having an external_id first, then by date + flavor_set = Flavor.objects.filter( + name=name, + category_id=category_id + ).order_by('-external_id', 'created_at') + + # Keep the first one, merge others into it + keep = flavor_set[0] + others = flavor_set[1:] + + self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})") + + for other in others: + # Re-assign ratings + Rating.objects.filter(flavor=other).update(flavor=keep) + # Note: This might trigger unique constraint errors on Rating (user, flavor) + # if the user rated both duplicates. We'll handle that if needed. + + # Re-assign other related models if any (e.g. notifications, etc.) + # In our case, Rating is the main one. + + # Delete the duplicate + other.delete() + + self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates.")) diff --git a/backend/api/management/commands/sync_flavors.py b/backend/api/management/commands/sync_flavors.py index 4a77fa5..c78e580 100644 --- a/backend/api/management/commands/sync_flavors.py +++ b/backend/api/management/commands/sync_flavors.py @@ -78,7 +78,7 @@ class Command(BaseCommand): synced_external_ids = [] for p in products: - title = p.get('title') + title = p.get('title', '').strip() tags = p.get('tags', []) if isinstance(tags, str): tags = [t.strip() for t in tags.split(',')] @@ -119,23 +119,26 @@ class Command(BaseCommand): handle = p.get('handle') shop_url = f"https://weareholy.com/products/{handle}" if handle else None - # Find or create + # 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: - # Try to find by name AND category to avoid Milkshake/Iced Tea name collisions + # 2. Try exact name match in category flavor = Flavor.objects.filter(name=title, category=category, is_legacy=False).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, is_legacy=False).first() + if not flavor: flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False) created_count += 1 else: updated_count += 1 - - flavor.category = category - flavor.description = description - flavor.shop_url = shop_url - flavor.is_available = is_available - flavor.external_id = p['id'] # Ensure ID is set + # Ensure external_id is updated if it was missing + if not flavor.external_id: + flavor.external_id = p['id'] if image_url: # Check if local file exists diff --git a/backend/api/views.py b/backend/api/views.py index b3598e6..92d1020 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -106,24 +106,62 @@ class FlavorViewSet(viewsets.ModelViewSet): # Filter by remaining query words in name or description if remaining_query: words = remaining_query.split() + # Construct a combined OR query for all words to be more permissive (fuzzy-ish) + word_query = Q() for word in words: - flavors = flavors.filter( - Q(name__icontains=word) | Q(description__icontains=word) - ) + if len(word) > 2: # Only filter by words longer than 2 chars to avoid noise + word_query |= Q(name__icontains=word) | Q(description__icontains=word) + + if word_query: + flavors = flavors.filter(word_query) - # Limit results for performance - results = [] - for f in flavors.order_by('name')[:15]: - results.append({ + # Limit results and deduplicate in Python to be safe against DB inconsistencies + results_list = [] + seen_keys = set() + + # Priority sorting in Python + search_lower = remaining_query.lower() + + # Order by name, but we'll also try to prioritize exact matches in Python + for f in flavors.order_by('name'): + # Create a unique key for this flavor (name + category) + key = f"{f.name.strip().lower()}|{f.category.slug}" + if key in seen_keys: + continue + + seen_keys.add(key) + + name_lower = f.name.lower() + relevance = 0 + if name_lower == search_lower: + relevance = 3 + elif name_lower.startswith(search_lower): + relevance = 2 + elif search_lower in name_lower: + relevance = 1 + + results_list.append({ 'id': f.id, 'name': f.name, 'type': 'flavor', 'subtitle': f.category.name, 'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url, - 'slug': None + 'slug': None, + 'relevance': relevance }) - return Response(results) + # Sort by relevance desc, then name + results_list.sort(key=lambda x: (-x['relevance'], x['name'])) + + # Remove relevance helper and limit to 15 + final_results = [] + for r in results_list: + del r['relevance'] + final_results.append(r) + if len(final_results) >= 15: + break + + return Response(final_results) @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) def top(self, request): diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 5c15797..aa67c5b 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -25,6 +25,10 @@ chmod -R 777 django_cache echo "Syncing flavors..." python manage.py sync_flavors +# Merge any duplicates that might have been created +echo "Cleaning up duplicates..." +python manage.py cleanup_duplicates + # Seed legacy flavors if needed echo "Seeding legacy flavors..." python manage.py seed_legacy_flavors