Implement group management features: add GroupManager component.

This commit is contained in:
SergeantPanda 2025-07-12 16:08:04 -05:00
parent 3006209ecc
commit fcce1a36b2
5 changed files with 413 additions and 2 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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(
<Badge key="channels" size="xs" color="blue" leftSection={<Tv size={10} />}>
Channels
</Badge>
);
}
if (usage?.hasM3UAccounts) {
badges.push(
<Badge key="m3u" size="xs" color="purple" leftSection={<Database size={10} />}>
M3U
</Badge>
);
}
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 (
<Modal
opened={isOpen}
onClose={onClose}
title="Group Manager"
size="md"
scrollAreaComponent={ScrollArea.Autosize}
>
<Stack>
<Alert icon={<AlertCircle size={16} />} color="blue" variant="light">
Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted.
</Alert>
{/* Create new group section */}
<Stack>
<Text size="sm" fw={600}>Create New Group</Text>
<Group>
{isCreating ? (
<>
<TextInput
placeholder="Enter group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
style={{ flex: 1 }}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
/>
<ActionIcon color="green" onClick={handleCreate}>
<Check size={16} />
</ActionIcon>
<ActionIcon color="gray" onClick={() => {
setIsCreating(false);
setNewGroupName('');
}}>
<X size={16} />
</ActionIcon>
</>
) : (
<Button
leftSection={<SquarePlus size={16} />}
variant="light"
size="sm"
onClick={() => setIsCreating(true)}
>
Add Group
</Button>
)}
</Group>
</Stack>
<Divider />
{/* Existing groups */}
<Stack>
<Text size="sm" fw={600}>Existing Groups ({Object.keys(channelGroups).length})</Text>
{loading ? (
<Text size="sm" c="dimmed">Loading group information...</Text>
) : Object.keys(channelGroups).length === 0 ? (
<Text size="sm" c="dimmed">No groups found</Text>
) : (
<Stack gap="xs">
{Object.values(channelGroups)
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => (
<Group key={group.id} justify="space-between" p="sm" style={{
border: '1px solid #e0e0e0',
borderRadius: '4px',
backgroundColor: editingGroup === group.id ? '#f8f9fa' : 'transparent'
}}>
<Stack gap={4} style={{ flex: 1 }}>
{editingGroup === group.id ? (
<TextInput
value={editName}
onChange={(e) => setEditName(e.target.value)}
size="sm"
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()}
/>
) : (
<>
<Text size="sm" fw={500}>{group.name}</Text>
<Group gap={4}>
{getGroupBadges(group)}
</Group>
</>
)}
</Stack>
<Group gap="xs">
{editingGroup === group.id ? (
<>
<ActionIcon color="green" size="sm" onClick={handleSaveEdit}>
<Check size={14} />
</ActionIcon>
<ActionIcon color="gray" size="sm" onClick={handleCancelEdit}>
<X size={14} />
</ActionIcon>
</>
) : (
<>
<ActionIcon
color="blue"
size="sm"
onClick={() => handleEdit(group)}
disabled={!canEditGroup(group)}
>
<SquarePen size={14} />
</ActionIcon>
<ActionIcon
color="red"
size="sm"
onClick={() => handleDelete(group)}
disabled={!canDeleteGroup(group)}
>
<Trash2 size={14} />
</ActionIcon>
</>
)}
</Group>
</Group>
))}
</Stack>
)}
</Stack>
<Divider />
<Flex justify="flex-end">
<Button variant="default" onClick={onClose}>
Close
</Button>
</Flex>
</Stack>
</Modal>
);
};
export default GroupManager;

View file

@ -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 = ({
<Text size="xs">Auto-Match</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item
leftSection={<Settings size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<UnstyledButton size="xs" onClick={() => setGroupManagerOpen(true)}>
<Text size="xs">Edit Groups</Text>
</UnstyledButton>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
@ -312,6 +324,11 @@ const ChannelTableHeader = ({
onClose={closeAssignChannelNumbersModal}
/>
<GroupManager
isOpen={groupManagerOpen}
onClose={() => setGroupManagerOpen(false)}
/>
<ConfirmationDialog
opened={confirmDeleteProfileOpen}
onClose={() => setConfirmDeleteProfileOpen(false)}

View file

@ -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 {