Add bulk delete abilites and cleanup unused action.

This commit is contained in:
SergeantPanda 2025-07-15 21:39:17 -05:00
parent 6a7abbaa82
commit fa470bee35
4 changed files with 337 additions and 44 deletions

View file

@ -6,6 +6,7 @@ from .api_views import (
ChannelGroupViewSet,
BulkDeleteStreamsAPIView,
BulkDeleteChannelsAPIView,
BulkDeleteLogosAPIView,
LogoViewSet,
ChannelProfileViewSet,
UpdateChannelMembershipAPIView,
@ -28,6 +29,8 @@ urlpatterns = [
# Bulk delete is a single APIView, not a ViewSet
path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'),
path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'),
path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'),
path('logos/cleanup/', BulkDeleteLogosAPIView.as_view(), {'action': 'cleanup_unused_logos'}, name='cleanup_unused_logos'),
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),

View file

@ -1023,6 +1023,79 @@ class BulkDeleteChannelsAPIView(APIView):
)
# ─────────────────────────────────────────────────────────
# 6) Bulk Delete Logos
# ─────────────────────────────────────────────────────────
class BulkDeleteLogosAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete logos by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["logo_ids"],
properties={
"logo_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Logo IDs to delete",
)
},
),
responses={204: "Logos deleted"},
)
def delete(self, request):
logo_ids = request.data.get("logo_ids", [])
# Check if any logos are being used by channels
used_logos = Logo.objects.filter(
id__in=logo_ids,
channels__isnull=False
).distinct()
if used_logos.exists():
used_names = list(used_logos.values_list('name', flat=True))
return Response(
{"error": f"Cannot delete logos that are in use: {', '.join(used_names)}"},
status=status.HTTP_400_BAD_REQUEST
)
# Delete logos that are not in use
deleted_count = Logo.objects.filter(id__in=logo_ids).delete()[0]
return Response(
{"message": f"Successfully deleted {deleted_count} logos"},
status=status.HTTP_204_NO_CONTENT
)
@swagger_auto_schema(
method="post",
operation_description="Delete all logos that are not used by any channels",
responses={200: "Cleanup completed"},
)
@action(detail=False, methods=["post"], url_path="cleanup")
def cleanup_unused_logos(self, request):
"""Delete all logos with no channel associations"""
unused_logos = Logo.objects.filter(channels__isnull=True)
deleted_count = unused_logos.count()
logo_names = list(unused_logos.values_list('name', flat=True))
# Delete the unused logos
unused_logos.delete()
return Response({
"message": f"Successfully deleted {deleted_count} unused logos",
"deleted_count": deleted_count,
"deleted_logos": logo_names
})
class LogoViewSet(viewsets.ModelViewSet):
queryset = Logo.objects.all()
serializer_class = LogoSerializer
@ -1043,19 +1116,19 @@ class LogoViewSet(viewsets.ModelViewSet):
def get_queryset(self):
"""Optimize queryset with prefetch and add filtering"""
queryset = Logo.objects.prefetch_related('channels').order_by('name')
# Filter by usage
used_filter = self.request.query_params.get('used', None)
if used_filter == 'true':
queryset = queryset.filter(channels__isnull=False).distinct()
elif used_filter == 'false':
queryset = queryset.filter(channels__isnull=True)
# Filter by name
name_filter = self.request.query_params.get('name', None)
if name_filter:
queryset = queryset.filter(name__icontains=name_filter)
return queryset
def create(self, request, *args, **kwargs):

View file

@ -1308,6 +1308,40 @@ export default class API {
}
}
static async deleteLogos(ids) {
try {
await request(`${host}/api/channels/logos/bulk-delete/`, {
method: 'DELETE',
body: { logo_ids: ids },
});
// Remove multiple logos from store
ids.forEach(id => {
useChannelsStore.getState().removeLogo(id);
});
return true;
} catch (e) {
errorNotification('Failed to delete logos', e);
}
}
static async cleanupUnusedLogos() {
try {
const response = await request(`${host}/api/channels/logos/cleanup/`, {
method: 'POST',
});
// Refresh logos to update the UI
await this.fetchLogos();
return response;
} catch (e) {
errorNotification('Failed to cleanup unused logos', e);
throw e;
}
}
static async getChannelProfiles() {
try {
const response = await request(`${host}/api/channels/profiles/`);

View file

@ -9,6 +9,8 @@ import {
SquarePen,
ExternalLink,
Filter,
Trash2,
Trash,
} from 'lucide-react';
import {
ActionIcon,
@ -28,6 +30,7 @@ import {
Select,
TextInput,
Menu,
Checkbox,
} from '@mantine/core';
import { CustomTable, useTable } from './CustomTable';
import ConfirmationDialog from '../ConfirmationDialog';
@ -89,11 +92,15 @@ const LogosTable = () => {
const [deleteTarget, setDeleteTarget] = useState(null);
const [logoToDelete, setLogoToDelete] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [isCleaningUp, setIsCleaningUp] = useState(false);
const [filters, setFilters] = useState({
name: '',
used: 'all'
});
const [debouncedNameFilter, setDebouncedNameFilter] = useState('');
const [selectedRows, setSelectedRows] = useState(new Set());
// Debounce the name filter
useEffect(() => {
@ -104,6 +111,27 @@ const LogosTable = () => {
return () => clearTimeout(timer);
}, [filters.name]);
const data = useMemo(() => {
const logosArray = Object.values(logos || {});
// Apply filters
let filteredLogos = logosArray;
if (debouncedNameFilter) {
filteredLogos = filteredLogos.filter(logo =>
logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase())
);
}
if (filters.used === 'used') {
filteredLogos = filteredLogos.filter(logo => logo.is_used);
} else if (filters.used === 'unused') {
filteredLogos = filteredLogos.filter(logo => !logo.is_used);
}
return filteredLogos.sort((a, b) => a.id - b.id);
}, [logos, debouncedNameFilter, filters.used]);
/**
* Functions
*/
@ -126,6 +154,61 @@ const LogosTable = () => {
} finally {
setIsLoading(false);
setConfirmDeleteOpen(false);
setDeleteTarget(null);
setLogoToDelete(null);
setIsBulkDelete(false);
setSelectedRows(new Set()); // Clear selections
}
}, [fetchLogos]);
const executeBulkDelete = useCallback(async () => {
if (selectedRows.size === 0) return;
setIsLoading(true);
try {
await API.deleteLogos(Array.from(selectedRows));
await fetchLogos();
notifications.show({
title: 'Success',
message: `${selectedRows.size} logos deleted successfully`,
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to delete logos',
color: 'red',
});
} finally {
setIsLoading(false);
setConfirmDeleteOpen(false);
setIsBulkDelete(false);
setSelectedRows(new Set()); // Clear selections
}
}, [selectedRows, fetchLogos]);
const executeCleanupUnused = useCallback(async () => {
setIsCleaningUp(true);
try {
const result = await API.cleanupUnusedLogos();
await fetchLogos(); // Refresh the logos list
notifications.show({
title: 'Cleanup Complete',
message: `Successfully deleted ${result.deleted_count} unused logos`,
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Cleanup Failed',
message: 'Failed to cleanup unused logos',
color: 'red',
});
} finally {
setIsCleaningUp(false);
setConfirmCleanupOpen(false);
setSelectedRows(new Set()); // Clear selections after cleanup
}
}, [fetchLogos]);
@ -139,14 +222,73 @@ const LogosTable = () => {
const logo = logosArray.find((l) => l.id === id);
setLogoToDelete(logo);
setDeleteTarget(id);
setIsBulkDelete(false);
setConfirmDeleteOpen(true);
}, [logos]);
const handleSelectRow = useCallback((id, checked) => {
setSelectedRows(prev => {
const newSet = new Set(prev);
if (checked) {
newSet.add(id);
} else {
newSet.delete(id);
}
return newSet;
});
}, []);
const handleSelectAll = useCallback((checked) => {
if (checked) {
setSelectedRows(new Set(data.map(logo => logo.id)));
} else {
setSelectedRows(new Set());
}
}, [data]);
const deleteBulkLogos = useCallback(() => {
if (selectedRows.size === 0) return;
setIsBulkDelete(true);
setLogoToDelete(null);
setDeleteTarget(Array.from(selectedRows));
setConfirmDeleteOpen(true);
}, [selectedRows]);
const handleCleanupUnused = useCallback(() => {
setConfirmCleanupOpen(true);
}, []);
// Clear selections when logos data changes (e.g., after filtering)
useEffect(() => {
setSelectedRows(new Set());
}, [data.length]);
/**
* useMemo
*/
const columns = useMemo(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={selectedRows.size > 0 && selectedRows.size === data.length}
indeterminate={selectedRows.size > 0 && selectedRows.size < data.length}
onChange={(event) => handleSelectAll(event.currentTarget.checked)}
size="sm"
/>
),
cell: ({ row }) => (
<Checkbox
checked={selectedRows.has(row.original.id)}
onChange={(event) => handleSelectRow(row.original.id, event.currentTarget.checked)}
size="sm"
/>
),
size: 50,
enableSorting: false,
},
{
header: 'Preview',
accessorKey: 'cache_url',
@ -256,7 +398,7 @@ const LogosTable = () => {
),
},
],
[theme, editLogo, deleteLogo]
[theme, editLogo, deleteLogo, selectedRows, handleSelectRow, handleSelectAll, data.length]
);
const closeLogoForm = () => {
@ -265,27 +407,6 @@ const LogosTable = () => {
fetchLogos(); // Refresh the logos list
};
const data = useMemo(() => {
const logosArray = Object.values(logos || {});
// Apply filters
let filteredLogos = logosArray;
if (debouncedNameFilter) {
filteredLogos = filteredLogos.filter(logo =>
logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase())
);
}
if (filters.used === 'used') {
filteredLogos = filteredLogos.filter(logo => logo.is_used);
} else if (filters.used === 'unused') {
filteredLogos = filteredLogos.filter(logo => !logo.is_used);
}
return filteredLogos.sort((a, b) => a.id - b.id);
}, [logos, debouncedNameFilter, filters.used]);
const renderHeaderCell = (header) => {
return (
<Text size="sm" name={header.id}>
@ -294,17 +415,22 @@ const LogosTable = () => {
);
};
const onRowSelectionChange = useCallback((newSelection) => {
setSelectedRows(new Set(newSelection));
}, []);
const table = useTable({
columns,
data,
allRowIds: data.map((logo) => logo.id),
enablePagination: false,
enableRowSelection: false,
enableRowSelection: true,
enableRowVirtualization: false,
renderTopToolbar: false,
manualSorting: false,
manualFiltering: false,
manualPagination: false,
onRowSelectionChange: onRowSelectionChange,
headerCellRenderFns: {
actions: renderHeaderCell,
cache_url: renderHeaderCell,
@ -394,21 +520,44 @@ const LogosTable = () => {
/>
</Group>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editLogo()}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
Add Logo
</Button>
<Group gap="sm">
<Button
leftSection={<Trash size={16} />}
variant="light"
size="xs"
color="orange"
onClick={handleCleanupUnused}
loading={isCleaningUp}
>
Cleanup Unused
</Button>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteBulkLogos}
disabled={selectedRows.size === 0}
>
Delete {selectedRows.size > 0 ? `(${selectedRows.size})` : ''}
</Button>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editLogo()}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
Add Logo
</Button>
</Group>
</Box>
{/* Table container */}
@ -437,10 +586,23 @@ const LogosTable = () => {
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => executeDeleteLogo(deleteTarget)}
title="Delete Logo"
onConfirm={() => {
if (isBulkDelete) {
executeBulkDelete();
} else {
executeDeleteLogo(deleteTarget);
}
}}
title={isBulkDelete ? "Delete Multiple Logos" : "Delete Logo"}
message={
logoToDelete ? (
isBulkDelete ? (
<div>
Are you sure you want to delete {selectedRows.size} selected logos?
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
) : logoToDelete ? (
<div>
Are you sure you want to delete the logo "{logoToDelete.name}"?
{logoToDelete.channel_count > 0 && (
@ -460,6 +622,27 @@ const LogosTable = () => {
cancelLabel="Cancel"
size="md"
/>
<ConfirmationDialog
opened={confirmCleanupOpen}
onClose={() => setConfirmCleanupOpen(false)}
onConfirm={executeCleanupUnused}
title="Cleanup Unused Logos"
message={
<div>
Are you sure you want to cleanup all unused logos?
<Text size="sm" c="dimmed" mt="xs">
This will permanently delete all logos that are not currently used by any channels.
</Text>
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
}
confirmLabel="Cleanup"
cancelLabel="Cancel"
size="md"
/>
</>
);
};