diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 5d00e84d..b4df2461 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -187,6 +187,40 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + """Add annotation for association counts""" + from django.db.models import Count + return ChannelGroup.objects.annotate( + channel_count=Count('channels', distinct=True), + m3u_account_count=Count('m3u_account', distinct=True) + ) + + def update(self, request, *args, **kwargs): + """Override update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Override partial_update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().partial_update(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): """Override destroy to check for associations before deletion""" instance = self.get_object() diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index cdc6ef60..4d1694dc 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -89,9 +89,12 @@ class StreamSerializer(serializers.ModelSerializer): # Channel Group # class ChannelGroupSerializer(serializers.ModelSerializer): + channel_count = serializers.IntegerField(read_only=True) + m3u_account_count = serializers.IntegerField(read_only=True) + class Meta: model = ChannelGroup - fields = ["id", "name"] + fields = ["id", "name", "channel_count", "m3u_account_count"] class ChannelProfileSerializer(serializers.ModelSerializer): diff --git a/frontend/src/api.js b/frontend/src/api.js index ff95f634..9786bb75 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -250,7 +250,15 @@ export default class API { }); if (response.id) { - useChannelsStore.getState().addChannelGroup(response); + // Add association flags for new groups + const processedGroup = { + ...response, + hasChannels: false, + hasM3UAccounts: false, + canEdit: true, + canDelete: true + }; + useChannelsStore.getState().addChannelGroup(processedGroup); } return response; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 452db052..64412cb4 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -45,6 +45,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const logos = useChannelsStore((s) => s.logos); const fetchLogos = useChannelsStore((s) => s.fetchLogos); const streams = useStreamsStore((state) => state.streams); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 2ba3245c..693ebb11 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -32,6 +32,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 18ed31c1..46641fb1 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -1,10 +1,17 @@ // Modal.js import React from 'react'; import API from '../../api'; -import { Flex, TextInput, Button, Modal } from '@mantine/core'; +import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { isNotEmpty, useForm } from '@mantine/form'; +import useChannelsStore from '../../store/channels'; const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + + // Check if editing is allowed + const canEdit = !channelGroup || canEditChannelGroup(channelGroup.id); + const form = useForm({ mode: 'uncontrolled', initialValues: { @@ -17,6 +24,16 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { }); const onSubmit = async () => { + // Prevent submission if editing is not allowed + if (channelGroup && !canEdit) { + notifications.show({ + title: 'Error', + message: 'Cannot edit group with M3U account associations', + color: 'red', + }); + return; + } + const values = form.getValues(); let newGroup; @@ -36,11 +53,17 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { return ( + {channelGroup && !canEdit && ( + + This group cannot be edited because it has M3U account associations. + + )}
@@ -50,7 +73,7 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { type="submit" variant="contained" color="primary" - disabled={form.submitting} + disabled={form.submitting || (channelGroup && !canEdit)} size="small" > Submit diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index e10b9a1c..f6bf7305 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -37,7 +37,9 @@ const GroupItem = React.memo(({ onCancelEdit, onEdit, onDelete, - groupUsage + groupUsage, + canEditGroup, + canDeleteGroup }) => { const getGroupBadges = (group) => { const usage = groupUsage[group.id]; @@ -62,16 +64,6 @@ const GroupItem = React.memo(({ return badges; }; - const canEditGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canEdit !== false; - }; - - const canDeleteGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; - }; - return ( { - // Use a more specific selector to avoid unnecessary re-renders - const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); const [editingGroup, setEditingGroup] = useState(null); const [editName, setEditName] = useState(''); const [newGroupName, setNewGroupName] = useState(''); @@ -171,21 +163,18 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } }, [isOpen]); - const fetchGroupUsage = async () => { + const fetchGroupUsage = useCallback(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 + // Use the actual channel group data that already has the flags 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 + hasChannels: group.hasChannels ?? false, + hasM3UAccounts: group.hasM3UAccounts ?? false, + canEdit: group.canEdit ?? true, + canDelete: group.canDelete ?? true }; }); @@ -195,7 +184,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } finally { setLoading(false); } - }; + }, [channelGroups]); const handleEdit = useCallback((group) => { setEditingGroup(group.id); @@ -414,6 +403,8 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { onEdit={handleEdit} onDelete={handleDelete} groupUsage={groupUsage} + canEditGroup={canEditChannelGroup} + canDeleteGroup={canDeleteChannelGroup} /> ))} @@ -431,5 +422,4 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { ); }); - export default GroupManager; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 7a9d5007..077602ad 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -216,6 +216,9 @@ const ChannelRowActions = React.memo( const ChannelsTable = ({ }) => { const theme = useMantineTheme(); + const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); /** * STORES @@ -241,7 +244,6 @@ const ChannelsTable = ({ }) => { const channels = useChannelsStore((s) => s.channels); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); - const channelGroups = useChannelsStore((s) => s.channelGroups); const logos = useChannelsStore((s) => s.logos); const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', { pageSize: 50, diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 03cf2b86..40791cf4 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -46,16 +46,24 @@ const useChannelsStore = create((set, get) => ({ }, fetchChannelGroups: async () => { - set({ isLoading: true, error: null }); try { const channelGroups = await api.getChannelGroups(); - set({ - channelGroups: channelGroups.reduce((acc, group) => { - acc[group.id] = group; - return acc; - }, {}), - isLoading: false, - }); + + // Process groups to add association flags + const processedGroups = channelGroups.reduce((acc, group) => { + acc[group.id] = { + ...group, + hasChannels: group.channel_count > 0, + hasM3UAccounts: group.m3u_account_count > 0, + canEdit: group.m3u_account_count === 0, + canDelete: group.channel_count === 0 && group.m3u_account_count === 0 + }; + return acc; + }, {}); + + set((state) => ({ + channelGroups: processedGroups, + })); } catch (error) { console.error('Failed to fetch channel groups:', error); set({ error: 'Failed to load channel groups.', isLoading: false }); @@ -435,6 +443,17 @@ const useChannelsStore = create((set, get) => ({ set({ error: 'Failed to load recordings.', isLoading: false }); } }, + + // Add helper methods for validation + canEditChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canEdit ?? true; + }, + + canDeleteChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canDelete ?? true; + }, })); export default useChannelsStore;