From 4ebfde27972fca38f467137247d6c34cb1c16da4 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 8 May 2025 12:43:27 -0400 Subject: [PATCH] auto-assign numbers now configurable by selection, channel enable switch now applies to all selected --- apps/channels/api_views.py | 39 +++++++++++--- frontend/src/api.js | 48 ++++++++++++++--- .../src/components/tables/ChannelsTable.jsx | 54 +++++-------------- .../ChannelsTable/ChannelTableHeader.jsx | 54 ++++++++++++++----- .../components/tables/CustomTable/index.jsx | 5 +- frontend/src/store/channels.jsx | 20 +++++++ 6 files changed, 152 insertions(+), 68 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 23d706ba..23632704 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -185,6 +185,30 @@ class ChannelViewSet(viewsets.ModelViewSet): context['include_streams'] = include_streams return context + @action(detail=False, methods=['patch'], url_path='edit/bulk') + def edit_bulk(self, request): + data_list = request.data + if not isinstance(data_list, list): + return Response({"error": "Expected a list of channel objects objects"}, status=status.HTTP_400_BAD_REQUEST) + + updated_channels = [] + try: + with transaction.atomic(): + for item in data_list: + channel = Channel.objects.id(id=item.pop('id')) + for key, value in item.items(): + setattr(channel, key, value) + + channel.save(update_fields=item.keys()) + updated_channels.append(channel) + except Exception as e: + logger.error("Error during bulk channel edit", e) + return Response({"error": e}, status=500) + + response_data = ChannelSerializer(updated_channels, many=True).data + + return Response(response_data, status=status.HTTP_200_OK) + @action(detail=False, methods=['get'], url_path='ids') def get_ids(self, request, *args, **kwargs): # Get the filtered queryset @@ -204,12 +228,13 @@ class ChannelViewSet(viewsets.ModelViewSet): operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, - required=["channel_order"], + required=["channel_ids"], properties={ - "channel_order": openapi.Schema( + "starting_number": openapi.Schema(type=openapi.TYPE_STRING, description="Starting channel number to assign"), + "channel_ids": openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of channel IDs in the new order" + description="Channel IDs to assign" ) } ), @@ -218,9 +243,11 @@ class ChannelViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['post'], url_path='assign') def assign(self, request): with transaction.atomic(): - channel_order = request.data.get('channel_order', []) - for order, channel_id in enumerate(channel_order, start=1): - Channel.objects.filter(id=channel_id).update(channel_number=order) + channel_ids = request.data.get('channel_ids', []) + channel_num = request.data.get('starting_number', 1) + for channel_id in channel_ids: + Channel.objects.filter(id=channel_id).update(channel_number=channel_num) + channel_num = channel_num + 1 return Response({"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK) diff --git a/frontend/src/api.js b/frontend/src/api.js index a4fb89b0..60b22634 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -378,6 +378,31 @@ export default class API { } } + static async updateChannels(ids, values) { + const body = []; + for (const id in ids) { + body.push({ + id, + ...values, + }); + } + + try { + const response = await request( + `${host}/api/channels/channels/edit/bulk/`, + { + method: 'PATCH', + body, + } + ); + + useChannelsStore.getState().updateChannels(response); + return response; + } catch (e) { + errorNotification('Failed to update channels', e); + } + } + static async setChannelEPG(channelId, epgDataId) { try { const response = await request( @@ -408,16 +433,13 @@ export default class API { } } - static async assignChannelNumbers(channelIds) { + static async assignChannelNumbers(channelIds, startingNum = 1) { try { const response = await request(`${host}/api/channels/channels/assign/`, { method: 'POST', - body: { channel_order: channelIds }, + body: { channel_ids: channelIds, starting_number: startingNum }, }); - // Optionally refesh the channel list in Zustand - // await useChannelsStore.getState().fetchChannels(); - return response; } catch (e) { errorNotification('Failed to assign channel #s', e); @@ -733,7 +755,11 @@ export default class API { try { // If this is just toggling the active state, make a simpler request - if (isToggle && 'is_active' in payload && Object.keys(payload).length === 1) { + if ( + isToggle && + 'is_active' in payload && + Object.keys(payload).length === 1 + ) { const response = await request(`${host}/api/m3u/accounts/${id}/`, { method: 'PATCH', body: { is_active: payload.is_active }, @@ -830,7 +856,11 @@ export default class API { try { // If this is just toggling the active state, make a simpler request - if (isToggle && 'is_active' in payload && Object.keys(payload).length === 1) { + if ( + isToggle && + 'is_active' in payload && + Object.keys(payload).length === 1 + ) { const response = await request(`${host}/api/epg/sources/${id}/`, { method: 'PATCH', body: { is_active: payload.is_active }, @@ -1344,7 +1374,9 @@ export default class API { static async getChannel(id) { try { - const response = await request(`${host}/api/channels/channels/${id}/?include_streams=true`); + const response = await request( + `${host}/api/channels/channels/${id}/?include_streams=true` + ); return response; } catch (e) { errorNotification('Failed to fetch channel details', e); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 15b377f0..c8f92fc1 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -57,7 +57,7 @@ const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/ const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`; const ChannelEnabledSwitch = React.memo( - ({ rowId, selectedProfileId, toggleChannelEnabled }) => { + ({ rowId, selectedProfileId, selectedTableIds }) => { // Directly extract the channels set once to avoid re-renders on every change. const isEnabled = useChannelsStore( useCallback( @@ -68,9 +68,17 @@ const ChannelEnabledSwitch = React.memo( ) ); - const handleToggle = useCallback(() => { - toggleChannelEnabled([rowId], !isEnabled); - }, [rowId, isEnabled, toggleChannelEnabled]); + const handleToggle = () => { + if (selectedTableIds.length > 1) { + API.updateProfileChannels( + selectedTableIds, + selectedProfileId, + !isEnabled + ); + } else { + API.updateProfileChannel(rowId, selectedProfileId, !isEnabled); + } + }; return (
@@ -465,21 +473,6 @@ const ChannelsTable = ({}) => { }); }; - const toggleChannelEnabled = useCallback( - async (channelIds, enabled) => { - if (channelIds.length == 1) { - await API.updateProfileChannel( - channelIds[0], - selectedProfileId, - enabled - ); - } else { - await API.updateProfileChannels(channelIds, selectedProfileId, enabled); - } - }, - [selectedProfileId] - ); - const closeChannelForm = () => { setChannel(null); setChannelModalOpen(false); @@ -547,22 +540,6 @@ const ChannelsTable = ({}) => { } }; - const EnabledHeaderSwitch = useCallback(() => { - let enabled = false; - for (const id of selectedChannelIds) { - if (selectedProfileChannelIds.has(id)) { - enabled = true; - break; - } - } - - const toggleSelected = () => { - toggleChannelEnabled(selectedChannelIds, !enabled); - }; - - return ; - }, [selectedChannelIds, selectedProfileChannelIds, data]); - /** * useEffect */ @@ -602,12 +579,12 @@ const ChannelsTable = ({}) => { { id: 'enabled', size: 45, - cell: ({ row }) => { + cell: ({ row, table }) => { return ( ); }, @@ -716,9 +693,6 @@ const ChannelsTable = ({}) => { switch (header.id) { case 'enabled': - // if (selectedProfileId !== '0' && table.selectedTableIds.length > 0) { - // return EnabledHeaderSwitch(); - // } return (
diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index fd510902..47b7a46c 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -5,8 +5,10 @@ import { Button, Flex, Group, + NumberInput, Popover, Select, + Text, TextInput, Tooltip, useMantineTheme, @@ -14,6 +16,7 @@ import { import { ArrowDown01, Binary, + Check, CircleCheck, SquareMinus, SquarePlus, @@ -87,6 +90,8 @@ const ChannelTableHeader = ({ }) => { const theme = useMantineTheme(); + const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); + const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId); @@ -110,11 +115,11 @@ const ChannelTableHeader = ({ const assignChannels = async () => { try { - // Get row order from the table - const rowOrder = rows.map((row) => row.original.id); - // Call our custom API endpoint - const result = await API.assignChannelNumbers(rowOrder); + const result = await API.assignChannelNumbers( + selectedTableIds, + channelNumAssignmentStart + ); // We might get { message: "Channels have been auto-assigned!" } notifications.show({ @@ -194,15 +199,38 @@ const ChannelTableHeader = ({ - + + + + + + + Start # + + + + + + + diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 0b1d824f..3be2dc2f 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -17,6 +17,7 @@ const useTable = ({ expandedRowRenderer = () => <>, onRowSelectionChange = null, getExpandedRowHeight = null, + state = [], ...options }) => { const [selectedTableIds, setSelectedTableIds] = useState([]); @@ -155,7 +156,9 @@ const useTable = ({ const rangeIds = allRowIds.slice(startIndex, endIndex + 1); // Preserve existing selections outside the range - const idsOutsideRange = selectedTableIds.filter(id => !rangeIds.includes(id)); + const idsOutsideRange = selectedTableIds.filter( + (id) => !rangeIds.includes(id) + ); const newSelection = [...new Set([...rangeIds, ...idsOutsideRange])]; updateSelectedTableIds(newSelection); diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 26c195b4..54b1b20f 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -151,6 +151,26 @@ const useChannelsStore = create((set, get) => ({ }, })), + updateChannels: (channels) => { + const channelsByUUID = {}; + const updatedChannels = channels.reduce((acc, chan) => { + channelsByUUID[chan.uuid] = chan; + acc[chan.id] = chan; + return acc; + }, {}); + + return set((state) => ({ + channels: { + ...state.channels, + ...updatedChannels, + }, + channelsByUUID: { + ...state.channelsByUUID, + ...channelsByUUID, + }, + })); + }, + removeChannels: (channelIds) => { set((state) => { const updatedChannels = { ...state.channels };