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
+380
-33
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.
|
||||
|
||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
||||
categories = Category.objects.all()
|
||||
total_merged = 0
|
||||
|
||||
for dup in duplicates:
|
||||
name = dup["name"]
|
||||
category_id = dup["category"]
|
||||
for category in categories:
|
||||
flavors = Flavor.objects.filter(category=category)
|
||||
grouped: dict[str, list[Flavor]] = {}
|
||||
|
||||
# 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"
|
||||
)
|
||||
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 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})"
|
||||
f"Merging {len(others)} duplicates into '{keep.name}' "
|
||||
f"(Category: {category.name}, ID: {keep.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()
|
||||
merge_flavors(keep, other)
|
||||
total_merged += 1
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -53,6 +53,7 @@ const AdminBanners = lazy(() => import('./pages/admin/AdminBanners'));
|
||||
const AdminJobs = lazy(() => import('./pages/admin/AdminJobs'));
|
||||
const AdminTickets = lazy(() => import('./pages/admin/AdminTickets'));
|
||||
const AdminConfig = lazy(() => import('./pages/admin/AdminConfig'));
|
||||
const AdminFlavors = lazy(() => import('./pages/admin/AdminFlavors'));
|
||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
|
||||
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
||||
@@ -338,6 +339,7 @@ const App: React.FC = () => {
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="users/:id" element={<AdminUserDetail />} />
|
||||
<Route path="user/:id" element={<AdminUserDetail />} />
|
||||
<Route path="flavors" element={<AdminFlavors />} />
|
||||
<Route path="banners" element={<AdminBanners />} />
|
||||
<Route path="jobs" element={<AdminJobs />} />
|
||||
<Route path="tickets" element={<AdminTickets />} />
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
Grid,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSnackbar } from '../../contexts/NotificationContext';
|
||||
import { useFlavorsList } from '../../api/queries/useFlavorsList';
|
||||
import api from '../../lib/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '../../api/keys';
|
||||
|
||||
const AdminFlavors: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { notify } = useSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: flavors = [], isLoading } = useFlavorsList();
|
||||
|
||||
const [keepFlavor, setKeepFlavor] = useState<any>(null);
|
||||
const [removeFlavor, setRemoveFlavor] = useState<any>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!keepFlavor || !removeFlavor) return;
|
||||
|
||||
setIsMerging(true);
|
||||
try {
|
||||
await api.post('admin/merge_flavors/', {
|
||||
keep_id: keepFlavor.id,
|
||||
remove_id: removeFlavor.id,
|
||||
});
|
||||
|
||||
notify({ message: t('admin.mergeSuccess', { defaultValue: 'Flavors merged successfully' }), severity: 'success' });
|
||||
|
||||
// Invalidate queries
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.flavors({ scope: 'all' }) });
|
||||
|
||||
// Reset selection
|
||||
setKeepFlavor(null);
|
||||
setRemoveFlavor(null);
|
||||
setConfirmOpen(false);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.error || err.message;
|
||||
notify({ message: `Error: ${errorMsg}`, severity: 'error' });
|
||||
} finally {
|
||||
setIsMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canMerge = keepFlavor && removeFlavor && keepFlavor.id !== removeFlavor.id;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>
|
||||
{t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })}
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ mb: 4 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('admin.mergeFlavors', { defaultValue: 'Merge Flavors' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('admin.mergeFlavorsDesc', {
|
||||
defaultValue: 'Combine two flavors into one. Ratings from the source flavor will be moved to the target flavor. If a user rated both, the more detailed review is kept.'
|
||||
})}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={12} md={5}>
|
||||
<Autocomplete
|
||||
options={flavors}
|
||||
getOptionLabel={(option) => `${option.name} (${option.category_name})`}
|
||||
value={removeFlavor}
|
||||
onChange={(_, newValue) => setRemoveFlavor(newValue)}
|
||||
loading={isLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t('admin.flavorToRemove', { defaultValue: 'Flavor to Remove (Source)' })}
|
||||
helperText={t('admin.flavorToRemoveHint', { defaultValue: 'This flavor will be DELETED' })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={2} sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4">→</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={5}>
|
||||
<Autocomplete
|
||||
options={flavors}
|
||||
getOptionLabel={(option) => `${option.name} (${option.category_name})`}
|
||||
value={keepFlavor}
|
||||
onChange={(_, newValue) => setKeepFlavor(newValue)}
|
||||
loading={isLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t('admin.flavorToKeep', { defaultValue: 'Flavor to Keep (Target)' })}
|
||||
helperText={t('admin.flavorToKeepHint', { defaultValue: 'This flavor will PRESERVED' })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
disabled={!canMerge}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
{t('admin.startMerge', { defaultValue: 'Start Merge' })}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={confirmOpen} onClose={() => !isMerging && setConfirmOpen(false)}>
|
||||
<DialogTitle>{t('admin.confirmMergeTitle', { defaultValue: 'Confirm Flavor Merge' })}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('admin.confirmMergeText', {
|
||||
defaultValue: 'Are you sure you want to merge "{{remove}}" into "{{keep}}"? This action is irreversible. "{{remove}}" will be deleted, and all its ratings will be transferred.',
|
||||
remove: removeFlavor?.name,
|
||||
keep: keepFlavor?.name
|
||||
})}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmOpen(false)} disabled={isMerging}>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
color="warning"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
disabled={isMerging}
|
||||
>
|
||||
{isMerging ? t('common.processing', { defaultValue: 'Processing...' }) : t('admin.confirmMerge', { defaultValue: 'Merge Flavors' })}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminFlavors;
|
||||
@@ -16,6 +16,7 @@ import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import LocalDrinkIcon from '@mui/icons-material/LocalDrink';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell } from '../../components/ui';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
@@ -23,6 +24,7 @@ import { useTitle } from '../../hooks/useTitle';
|
||||
const SECTIONS = [
|
||||
{ value: 'overview', icon: <SettingsIcon /> },
|
||||
{ value: 'users', icon: <PeopleIcon /> },
|
||||
{ value: 'flavors', icon: <LocalDrinkIcon /> },
|
||||
{ value: 'banners', icon: <WallpaperIcon /> },
|
||||
{ value: 'jobs', icon: <TerminalIcon /> },
|
||||
{ value: 'tickets', icon: <HelpOutlineIcon /> },
|
||||
|
||||
Reference in new issue
Block a user