mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-24 18:48:36 +00:00
Add bulk delete abilites and cleanup unused action.
This commit is contained in:
parent
6a7abbaa82
commit
fa470bee35
4 changed files with 337 additions and 44 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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/`);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue