From fcce1a36b27236dc8f4e06f409432e78a54032a0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 16:08:04 -0500 Subject: [PATCH] Implement group management features: add GroupManager component. --- apps/channels/api_views.py | 20 + frontend/src/api.js | 16 + .../src/components/forms/GroupManager.jsx | 350 ++++++++++++++++++ .../ChannelsTable/ChannelTableHeader.jsx | 17 + frontend/src/store/channels.jsx | 12 +- 5 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/forms/GroupManager.jsx diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b651081e..5d00e84d 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -187,6 +187,26 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def destroy(self, request, *args, **kwargs): + """Override destroy to check for associations before deletion""" + instance = self.get_object() + + # Check if group has associated channels + if instance.channels.exists(): + return Response( + {"error": "Cannot delete group with associated channels"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot delete group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().destroy(request, *args, **kwargs) + # ───────────────────────────────────────────────────────── # 3) Channel Management (CRUD) diff --git a/frontend/src/api.js b/frontend/src/api.js index d75edfa9..ff95f634 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -277,6 +277,22 @@ export default class API { } } + static async deleteChannelGroup(id) { + try { + await request(`${host}/api/channels/groups/${id}/`, { + method: 'DELETE', + }); + + // Remove from store after successful deletion + useChannelsStore.getState().removeChannelGroup(id); + + return true; + } catch (e) { + errorNotification('Failed to delete channel group', e); + throw e; + } + } + static async addChannel(channel) { try { let body = null; diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx new file mode 100644 index 00000000..7709416f --- /dev/null +++ b/frontend/src/components/forms/GroupManager.jsx @@ -0,0 +1,350 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Stack, + Group, + Text, + TextInput, + Button, + ActionIcon, + Flex, + Badge, + Alert, + Divider, + ScrollArea, +} from '@mantine/core'; +import { + SquarePlus, + SquarePen, + Trash2, + Check, + X, + AlertCircle, + Database, + Tv +} from 'lucide-react'; +import { notifications } from '@mantine/notifications'; +import useChannelsStore from '../../store/channels'; +import API from '../../api'; + +const GroupManager = ({ isOpen, onClose }) => { + const channelGroups = useChannelsStore((s) => s.channelGroups); + const [editingGroup, setEditingGroup] = useState(null); + const [editName, setEditName] = useState(''); + const [newGroupName, setNewGroupName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [groupUsage, setGroupUsage] = useState({}); + const [loading, setLoading] = useState(false); + + // Fetch group usage information when modal opens + useEffect(() => { + if (isOpen) { + fetchGroupUsage(); + } + }, [isOpen]); + + const fetchGroupUsage = async () => { + setLoading(true); + try { + // This would ideally be a dedicated API endpoint, but we'll use the existing data + // For now, we'll determine usage based on the group having associated data + const usage = {}; + + // Check which groups have channels or M3U associations + // This is a simplified check - in a real implementation you'd want a dedicated API + Object.values(channelGroups).forEach(group => { + usage[group.id] = { + hasChannels: false, // Would need API call to check + hasM3UAccounts: false, // Would need API call to check + canEdit: true, // Assume editable unless proven otherwise + canDelete: true // Assume deletable unless proven otherwise + }; + }); + + setGroupUsage(usage); + } catch (error) { + console.error('Error fetching group usage:', error); + } finally { + setLoading(false); + } + }; + + const handleEdit = (group) => { + setEditingGroup(group.id); + setEditName(group.name); + }; + + const handleSaveEdit = async () => { + if (!editName.trim()) { + notifications.show({ + title: 'Error', + message: 'Group name cannot be empty', + color: 'red', + }); + return; + } + + try { + await API.updateChannelGroup({ + id: editingGroup, + name: editName.trim(), + }); + + notifications.show({ + title: 'Success', + message: 'Group updated successfully', + color: 'green', + }); + + setEditingGroup(null); + setEditName(''); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to update group', + color: 'red', + }); + } + }; + + const handleCancelEdit = () => { + setEditingGroup(null); + setEditName(''); + }; + + const handleCreate = async () => { + if (!newGroupName.trim()) { + notifications.show({ + title: 'Error', + message: 'Group name cannot be empty', + color: 'red', + }); + return; + } + + try { + await API.addChannelGroup({ + name: newGroupName.trim(), + }); + + notifications.show({ + title: 'Success', + message: 'Group created successfully', + color: 'green', + }); + + setNewGroupName(''); + setIsCreating(false); + fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to create group', + color: 'red', + }); + } + }; + + const handleDelete = async (group) => { + const usage = groupUsage[group.id]; + + if (usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)) { + notifications.show({ + title: 'Cannot Delete', + message: 'This group is associated with channels or M3U accounts and cannot be deleted', + color: 'orange', + }); + return; + } + + try { + await API.deleteChannelGroup(group.id); + + notifications.show({ + title: 'Success', + message: 'Group deleted successfully', + color: 'green', + }); + + fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete group', + color: 'red', + }); + } + }; + + const getGroupBadges = (group) => { + const usage = groupUsage[group.id]; + const badges = []; + + if (usage?.hasChannels) { + badges.push( + }> + Channels + + ); + } + + if (usage?.hasM3UAccounts) { + badges.push( + }> + M3U + + ); + } + + return badges; + }; + + const canEditGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canEdit !== false; // Default to true if no usage data + }; + + const canDeleteGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; + }; + + if (!isOpen) return null; + + return ( + + + } color="blue" variant="light"> + Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. + + + {/* Create new group section */} + + Create New Group + + {isCreating ? ( + <> + setNewGroupName(e.target.value)} + style={{ flex: 1 }} + onKeyPress={(e) => e.key === 'Enter' && handleCreate()} + /> + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + + + + + {/* Existing groups */} + + Existing Groups ({Object.keys(channelGroups).length}) + + {loading ? ( + Loading group information... + ) : Object.keys(channelGroups).length === 0 ? ( + No groups found + ) : ( + + {Object.values(channelGroups) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((group) => ( + + + {editingGroup === group.id ? ( + setEditName(e.target.value)} + size="sm" + onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()} + /> + ) : ( + <> + {group.name} + + {getGroupBadges(group)} + + + )} + + + + {editingGroup === group.id ? ( + <> + + + + + + + + ) : ( + <> + handleEdit(group)} + disabled={!canEditGroup(group)} + > + + + handleDelete(group)} + disabled={!canDeleteGroup(group)} + > + + + + )} + + + ))} + + )} + + + + + + + + + + ); +}; + +export default GroupManager; diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 8813ceda..1568e10d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -25,6 +25,7 @@ import { SquareMinus, SquarePen, SquarePlus, + Settings, } from 'lucide-react'; import API from '../../../api'; import { notifications } from '@mantine/notifications'; @@ -32,6 +33,7 @@ import useChannelsStore from '../../../store/channels'; import useAuthStore from '../../../store/auth'; import { USER_LEVELS } from '../../../constants'; import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers'; +import GroupManager from '../../forms/GroupManager'; import ConfirmationDialog from '../../ConfirmationDialog'; import useWarningsStore from '../../../store/warnings'; @@ -105,6 +107,7 @@ const ChannelTableHeader = ({ const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); + const [groupManagerOpen, setGroupManagerOpen] = useState(false); const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false); const [profileToDelete, setProfileToDelete] = useState(null); @@ -301,6 +304,15 @@ const ChannelTableHeader = ({ Auto-Match + + } + disabled={authUser.user_level != USER_LEVELS.ADMIN} + > + setGroupManagerOpen(true)}> + Edit Groups + + @@ -312,6 +324,11 @@ const ChannelTableHeader = ({ onClose={closeAssignChannelNumbersModal} /> + setGroupManagerOpen(false)} + /> + setConfirmDeleteProfileOpen(false)} diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index beb62fe1..03cf2b86 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -204,10 +204,18 @@ const useChannelsStore = create((set, get) => ({ updateChannelGroup: (channelGroup) => set((state) => ({ - ...state.channelGroups, - [channelGroup.id]: channelGroup, + channelGroups: { + ...state.channelGroups, + [channelGroup.id]: channelGroup, + }, })), + removeChannelGroup: (groupId) => + set((state) => { + const { [groupId]: removed, ...remainingGroups } = state.channelGroups; + return { channelGroups: remainingGroups }; + }), + fetchLogos: async () => { set({ isLoading: true, error: null }); try {