mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
implemented merging
This commit is contained in:
1 parent
1e6a6b93b1
commit
41c1b3c574
7 files changed
+390
-43
No files matched your search
@@ -1,51 +1,62 @@
|
||||
import re
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, F, Func, Value, CharField
|
||||
|
||||
from api.models import Flavor, Rating
|
||||
from api.models import Flavor, Category
|
||||
from api.services.flavor import merge_flavors
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Identifies and merges duplicate flavors (same name and category)"
|
||||
help = "Identifies and merges duplicate flavors (normalized name and category)"
|
||||
|
||||
def normalize_name(self, name: str) -> str:
|
||||
"""Strip common suffixes and normalize for comparison."""
|
||||
# Strip common suffixes
|
||||
suffixes = [
|
||||
r"\s+Energy$",
|
||||
r"\s+Hydration$",
|
||||
r"\s+Iced\s+Tea$",
|
||||
r"\s+\(Legacy\)$",
|
||||
]
|
||||
norm = name.strip()
|
||||
for suffix in suffixes:
|
||||
norm = re.sub(suffix, "", norm, flags=re.IGNORECASE)
|
||||
return norm.strip().lower()
|
||||
|
||||
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)
|
||||
)
|
||||
# We need to find duplicates based on normalized names within each category
|
||||
# Since we can't easily do normalization in a single SQL query with complex regex,
|
||||
# we'll fetch all flavors and group them in Python.
|
||||
|
||||
categories = Category.objects.all()
|
||||
total_merged = 0
|
||||
|
||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
||||
for category in categories:
|
||||
flavors = Flavor.objects.filter(category=category)
|
||||
grouped: dict[str, list[Flavor]] = {}
|
||||
|
||||
for f in flavors:
|
||||
norm = self.normalize_name(f.name)
|
||||
if norm not in grouped:
|
||||
grouped[norm] = []
|
||||
grouped[norm].append(f)
|
||||
|
||||
for norm_name, flavor_set in grouped.items():
|
||||
if len(flavor_set) > 1:
|
||||
# Sort: external_id first (current catalog), then created_at
|
||||
# We want to keep the one with external_id
|
||||
flavor_set.sort(key=lambda x: (x.external_id is None, x.created_at))
|
||||
|
||||
keep = flavor_set[0]
|
||||
others = flavor_set[1:]
|
||||
|
||||
self.stdout.write(
|
||||
f"Merging {len(others)} duplicates into '{keep.name}' "
|
||||
f"(Category: {category.name}, ID: {keep.id})"
|
||||
)
|
||||
|
||||
for other in others:
|
||||
merge_flavors(keep, other)
|
||||
total_merged += 1
|
||||
|
||||
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
|
||||
for rating in Rating.objects.filter(flavor=other):
|
||||
try:
|
||||
rating.flavor = keep
|
||||
rating.save()
|
||||
except Exception:
|
||||
# User already has a rating for the 'keep' flavor
|
||||
# Delete the duplicate rating instead
|
||||
rating.delete()
|
||||
|
||||
# Delete the duplicate flavor
|
||||
other.delete()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates."))
|
||||
self.stdout.write(self.style.SUCCESS(f"Finished cleaning up {total_merged} duplicates."))
|
||||
@@ -0,0 +1,48 @@
|
||||
from django.db import transaction
|
||||
from api.models import Flavor, Rating, Reply
|
||||
|
||||
def merge_flavors(keep_flavor: Flavor, remove_flavor: Flavor) -> None:
|
||||
"""
|
||||
Merges remove_flavor into keep_flavor.
|
||||
Re-assigns all ratings and ensures integrity by keeping the most detailed
|
||||
review if a user has rated both.
|
||||
"""
|
||||
if keep_flavor.id == remove_flavor.id:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# Re-assign or resolve ratings
|
||||
remove_ratings = Rating.objects.filter(flavor=remove_flavor)
|
||||
|
||||
for rating in remove_ratings:
|
||||
# Check if user already rated the target flavor
|
||||
existing_rating = Rating.objects.filter(user=rating.user, flavor=keep_flavor).first()
|
||||
|
||||
if not existing_rating:
|
||||
# Safe to just move it
|
||||
rating.flavor = keep_flavor
|
||||
rating.save()
|
||||
else:
|
||||
# Conflict Resolution: Keep the longest comment
|
||||
keep_comment = existing_rating.comment or ""
|
||||
remove_comment = rating.comment or ""
|
||||
|
||||
if len(remove_comment) > len(keep_comment):
|
||||
# Update the one we are keeping to have the better content from the one being removed
|
||||
existing_rating.score = rating.score
|
||||
existing_rating.comment = rating.comment
|
||||
existing_rating.save()
|
||||
|
||||
# Move ALL replies from the rating being deleted to the one being kept
|
||||
Reply.objects.filter(rating=rating).update(rating=existing_rating)
|
||||
|
||||
# Delete the redundant rating
|
||||
rating.delete()
|
||||
|
||||
# Transfer external_id if keep_flavor doesn't have one
|
||||
if not keep_flavor.external_id and remove_flavor.external_id:
|
||||
keep_flavor.external_id = remove_flavor.external_id
|
||||
keep_flavor.save(update_fields=["external_id"])
|
||||
|
||||
# Finally, delete the flavor
|
||||
remove_flavor.delete()
|
||||
@@ -0,0 +1,89 @@
|
||||
from django.test import TestCase
|
||||
from api.models import Category, Flavor, Rating, Reply, User
|
||||
from api.services.flavor import merge_flavors
|
||||
from api.management.commands.cleanup_duplicates import Command
|
||||
from io import StringIO
|
||||
|
||||
class FlavorMergeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.cat = Category.objects.create(name="Energy", slug="energy")
|
||||
self.user1 = User.objects.create_user(username="user1")
|
||||
self.user2 = User.objects.create_user(username="user2")
|
||||
|
||||
self.keep = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat)
|
||||
self.remove = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat)
|
||||
|
||||
def test_basic_merge(self):
|
||||
# User 1 rates 'remove'
|
||||
r1 = Rating.objects.create(user=self.user1, flavor=self.remove, score=8, comment="Good!")
|
||||
|
||||
merge_flavors(self.keep, self.remove)
|
||||
|
||||
self.assertFalse(Flavor.objects.filter(id=self.remove.id).exists())
|
||||
r1.refresh_from_db()
|
||||
self.assertEqual(r1.flavor, self.keep)
|
||||
|
||||
def test_merge_with_conflict_keeps_longest_comment(self):
|
||||
# User 1 rates both
|
||||
r_short = Rating.objects.create(user=self.user1, flavor=self.keep, score=5, comment="Short")
|
||||
r_long = Rating.objects.create(user=self.user1, flavor=self.remove, score=9, comment="Very long and detailed review")
|
||||
|
||||
# Add a reply to the one being deleted
|
||||
reply = Reply.objects.create(user=self.user2, rating=r_long, text="Nice review!")
|
||||
|
||||
merge_flavors(self.keep, self.remove)
|
||||
|
||||
self.assertEqual(Rating.objects.filter(user=self.user1, flavor=self.keep).count(), 1)
|
||||
kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep)
|
||||
self.assertEqual(kept_rating.comment, "Very long and detailed review")
|
||||
self.assertEqual(kept_rating.score, 9)
|
||||
|
||||
# Verify reply moved
|
||||
reply.refresh_from_db()
|
||||
self.assertEqual(reply.rating, kept_rating)
|
||||
|
||||
def test_merge_with_conflict_preserves_replies_from_discarded(self):
|
||||
r_keep = Rating.objects.create(user=self.user1, flavor=self.keep, score=10, comment="Best ever!")
|
||||
r_remove = Rating.objects.create(user=self.user1, flavor=self.remove, score=8, comment="Meh")
|
||||
|
||||
reply_on_remove = Reply.objects.create(user=self.user2, rating=r_remove, text="I disagree")
|
||||
|
||||
merge_flavors(self.keep, self.remove)
|
||||
|
||||
kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep)
|
||||
self.assertEqual(kept_rating.comment, "Best ever!")
|
||||
|
||||
reply_on_remove.refresh_from_db()
|
||||
self.assertEqual(reply_on_remove.rating, kept_rating)
|
||||
|
||||
class CleanupDuplicatesTests(TestCase):
|
||||
def setUp(self):
|
||||
self.cat = Category.objects.create(name="Energy", slug="energy")
|
||||
self.other_cat = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
||||
|
||||
def test_normalization_logic(self):
|
||||
f1 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat)
|
||||
f2 = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat)
|
||||
f3 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.other_cat)
|
||||
|
||||
out = StringIO()
|
||||
cmd = Command()
|
||||
cmd.stdout = out
|
||||
cmd.handle()
|
||||
|
||||
self.assertTrue(Flavor.objects.filter(id=f1.id).exists())
|
||||
self.assertFalse(Flavor.objects.filter(id=f2.id).exists())
|
||||
self.assertTrue(Flavor.objects.filter(id=f3.id).exists()) # Different category
|
||||
|
||||
def test_various_suffixes(self):
|
||||
Flavor.objects.create(name="Berry", category=self.cat)
|
||||
Flavor.objects.create(name="Berry Energy", category=self.cat)
|
||||
Flavor.objects.create(name="Berry Hydration", category=self.cat)
|
||||
Flavor.objects.create(name="Berry Iced Tea", category=self.cat)
|
||||
Flavor.objects.create(name="Berry (Legacy)", category=self.cat)
|
||||
|
||||
cmd = Command()
|
||||
cmd.stdout = StringIO()
|
||||
cmd.handle()
|
||||
|
||||
self.assertEqual(Flavor.objects.filter(category=self.cat).count(), 1)
|
||||
@@ -8,7 +8,8 @@ from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.models import Job, Rating, Reply, SystemConfig, Ticket, User
|
||||
from api.models import Flavor, Job, Rating, Reply, SystemConfig, Ticket, User
|
||||
from api.services.flavor import merge_flavors
|
||||
from api.serializers import (
|
||||
AdminUserDetailSerializer,
|
||||
AdminUserListSerializer,
|
||||
@@ -154,6 +155,32 @@ class AdminViewSet(viewsets.ViewSet):
|
||||
_sync_periodic_task(job)
|
||||
return Response(JobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def merge_flavors(self, request: Request) -> Response:
|
||||
keep_id = request.data.get("keep_id")
|
||||
remove_id = request.data.get("remove_id")
|
||||
|
||||
if not keep_id or not remove_id:
|
||||
return Response(
|
||||
{"error": "Both keep_id and remove_id are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
keep_flavor = Flavor.objects.get(pk=keep_id)
|
||||
remove_flavor = Flavor.objects.get(pk=remove_id)
|
||||
except Flavor.DoesNotExist:
|
||||
return Response({"error": "One or both flavors not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if keep_flavor.category_id != remove_flavor.category_id:
|
||||
return Response(
|
||||
{"error": "Flavors must belong to the same category"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
merge_flavors(keep_flavor, remove_flavor)
|
||||
return Response({"status": "Flavors merged successfully"})
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def send_test_email(self, request: Request) -> Response:
|
||||
me = current_user(request)
|
||||
|
||||
Reference in new issue
Block a user