implemented merging

This commit is contained in:
nvrl committed 2026-05-27 16:42:59 +02:00
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.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): 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): def handle(self, *args, **options):
# Find duplicates based on name and category # We need to find duplicates based on normalized names within each category
duplicates = ( # Since we can't easily do normalization in a single SQL query with complex regex,
Flavor.objects.values("name", "category") # we'll fetch all flavors and group them in Python.
.annotate(name_count=Count("id"))
.filter(name_count__gt=1)
)
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.") categories = Category.objects.all()
total_merged = 0
for dup in duplicates: for category in categories:
name = dup["name"] flavors = Flavor.objects.filter(category=category)
category_id = dup["category"] grouped: dict[str, list[Flavor]] = {}
# Get all flavors in this set, ordered by having an external_id first, then by date for f in flavors:
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by( norm = self.normalize_name(f.name)
"-external_id", "created_at" 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] keep = flavor_set[0]
others = flavor_set[1:] others = flavor_set[1:]
self.stdout.write( 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: for other in others:
# Re-assign ratings merge_flavors(keep, other)
for rating in Rating.objects.filter(flavor=other): total_merged += 1
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 self.stdout.write(self.style.SUCCESS(f"Finished cleaning up {total_merged} duplicates."))
other.delete()
self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates."))
+48
View File
@@ -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()
+89
View File
@@ -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)
+28 -1
View File
@@ -8,7 +8,8 @@ from rest_framework.decorators import action
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response 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 ( from api.serializers import (
AdminUserDetailSerializer, AdminUserDetailSerializer,
AdminUserListSerializer, AdminUserListSerializer,
@@ -154,6 +155,32 @@ class AdminViewSet(viewsets.ViewSet):
_sync_periodic_task(job) _sync_periodic_task(job)
return Response(JobSerializer(job).data) 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"]) @action(detail=False, methods=["post"])
def send_test_email(self, request: Request) -> Response: def send_test_email(self, request: Request) -> Response:
me = current_user(request) me = current_user(request)
+2
View File
@@ -53,6 +53,7 @@ const AdminBanners = lazy(() => import('./pages/admin/AdminBanners'));
const AdminJobs = lazy(() => import('./pages/admin/AdminJobs')); const AdminJobs = lazy(() => import('./pages/admin/AdminJobs'));
const AdminTickets = lazy(() => import('./pages/admin/AdminTickets')); const AdminTickets = lazy(() => import('./pages/admin/AdminTickets'));
const AdminConfig = lazy(() => import('./pages/admin/AdminConfig')); const AdminConfig = lazy(() => import('./pages/admin/AdminConfig'));
const AdminFlavors = lazy(() => import('./pages/admin/AdminFlavors'));
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail')); const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail')); const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
@@ -338,6 +339,7 @@ const App: React.FC = () => {
<Route path="users" element={<AdminUsers />} /> <Route path="users" element={<AdminUsers />} />
<Route path="users/:id" element={<AdminUserDetail />} /> <Route path="users/:id" element={<AdminUserDetail />} />
<Route path="user/:id" element={<AdminUserDetail />} /> <Route path="user/:id" element={<AdminUserDetail />} />
<Route path="flavors" element={<AdminFlavors />} />
<Route path="banners" element={<AdminBanners />} /> <Route path="banners" element={<AdminBanners />} />
<Route path="jobs" element={<AdminJobs />} /> <Route path="jobs" element={<AdminJobs />} />
<Route path="tickets" element={<AdminTickets />} /> <Route path="tickets" element={<AdminTickets />} />
+168
View File
@@ -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;
+2
View File
@@ -16,6 +16,7 @@ import WallpaperIcon from '@mui/icons-material/Wallpaper';
import TerminalIcon from '@mui/icons-material/Terminal'; import TerminalIcon from '@mui/icons-material/Terminal';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import LocalDrinkIcon from '@mui/icons-material/LocalDrink';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PageShell } from '../../components/ui'; import { PageShell } from '../../components/ui';
import { useTitle } from '../../hooks/useTitle'; import { useTitle } from '../../hooks/useTitle';
@@ -23,6 +24,7 @@ import { useTitle } from '../../hooks/useTitle';
const SECTIONS = [ const SECTIONS = [
{ value: 'overview', icon: <SettingsIcon /> }, { value: 'overview', icon: <SettingsIcon /> },
{ value: 'users', icon: <PeopleIcon /> }, { value: 'users', icon: <PeopleIcon /> },
{ value: 'flavors', icon: <LocalDrinkIcon /> },
{ value: 'banners', icon: <WallpaperIcon /> }, { value: 'banners', icon: <WallpaperIcon /> },
{ value: 'jobs', icon: <TerminalIcon /> }, { value: 'jobs', icon: <TerminalIcon /> },
{ value: 'tickets', icon: <HelpOutlineIcon /> }, { value: 'tickets', icon: <HelpOutlineIcon /> },