Enhancement: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access.

This commit is contained in:
SergeantPanda 2026-01-12 11:29:33 -06:00
parent 564dceb210
commit f821dabe8e
5 changed files with 299 additions and 23 deletions

View file

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Channel profile edit and duplicate functionality: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access.
- ProfileModal component extracted for improved code organization and maintainability of channel profile management operations.
- Frontend unit tests for pages and utilities: Added comprehensive unit test coverage for frontend components within pages/ and JS files within utils/, along with a GitHub Actions workflow (`frontend-tests.yml`) to automatically run tests on commits and pull requests - Thanks [@nick4810](https://github.com/nick4810)
- Channel Profile membership control for manual channel creation and bulk operations: Extended the existing `channel_profile_ids` parameter from `POST /api/channels/from-stream/` to also support `POST /api/channels/` (manual creation) and bulk creation tasks with the same flexible semantics:
- Omitted parameter (default): Channels are added to ALL profiles (preserves backward compatibility)

View file

@ -130,6 +130,8 @@ class StreamViewSet(viewsets.ModelViewSet):
ordering = ["-name"]
def get_permissions(self):
if self.action == "duplicate":
return [IsAdmin()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
@ -1728,11 +1730,58 @@ class ChannelProfileViewSet(viewsets.ModelViewSet):
return self.request.user.channel_profiles.all()
def get_permissions(self):
if self.action == "duplicate":
return [IsAdmin()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
@action(detail=True, methods=["post"], url_path="duplicate", permission_classes=[IsAdmin])
def duplicate(self, request, pk=None):
requested_name = str(request.data.get("name", "")).strip()
if not requested_name:
return Response(
{"detail": "Name is required to duplicate a profile."},
status=status.HTTP_400_BAD_REQUEST,
)
if ChannelProfile.objects.filter(name=requested_name).exists():
return Response(
{"detail": "A channel profile with this name already exists."},
status=status.HTTP_400_BAD_REQUEST,
)
source_profile = self.get_object()
with transaction.atomic():
new_profile = ChannelProfile.objects.create(name=requested_name)
source_memberships = ChannelProfileMembership.objects.filter(
channel_profile=source_profile
)
source_enabled_map = {
membership.channel_id: membership.enabled
for membership in source_memberships
}
new_memberships = list(
ChannelProfileMembership.objects.filter(channel_profile=new_profile)
)
for membership in new_memberships:
membership.enabled = source_enabled_map.get(
membership.channel_id, False
)
if new_memberships:
ChannelProfileMembership.objects.bulk_update(
new_memberships, ["enabled"]
)
serializer = self.get_serializer(new_profile)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class GetChannelStreamsAPIView(APIView):
def get_permissions(self):
@ -1861,7 +1910,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
def get_permissions(self):
# Allow unauthenticated playback of recording files (like other streaming endpoints)
if getattr(self, 'action', None) == 'file':
if self.action == 'file':
return [AllowAny()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]

View file

@ -2121,6 +2121,24 @@ export default class API {
}
}
static async duplicateChannelProfile(id, name) {
try {
const response = await request(
`${host}/api/channels/profiles/${id}/duplicate/`,
{
method: 'POST',
body: { name },
}
);
useChannelsStore.getState().addProfile(response);
return response;
} catch (e) {
errorNotification(`Failed to duplicate channel profile ${id}`, e);
}
}
static async deleteChannelProfile(id) {
try {
await request(`${host}/api/channels/profiles/${id}/`, {

View file

@ -0,0 +1,193 @@
import React, { useState, useEffect } from 'react';
import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { Copy, SquareMinus, SquarePen } from 'lucide-react';
import API from '../../api';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS } from '../../constants';
const ProfileModal = ({ opened, onClose, mode, profile }) => {
const [profileNameInput, setProfileNameInput] = useState('');
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
useEffect(() => {
if (opened && profile) {
setProfileNameInput(
mode === 'duplicate' ? `${profile.name} Copy` : profile.name
);
}
}, [opened, mode, profile]);
const closeModal = () => {
setProfileNameInput('');
onClose();
};
const submitProfileModal = async () => {
const trimmedName = profileNameInput.trim();
if (!mode || !profile) return;
if (!trimmedName) {
notifications.show({
title: 'Profile name is required',
color: 'red.5',
});
return;
}
if (mode === 'edit') {
if (trimmedName === profile.name) {
closeModal();
return;
}
const updatedProfile = await API.updateChannelProfile({
id: profile.id,
name: trimmedName,
});
if (updatedProfile) {
notifications.show({
title: 'Profile renamed',
message: `${profile.name}${trimmedName}`,
color: 'green.5',
});
closeModal();
}
}
if (mode === 'duplicate') {
const duplicatedProfile = await API.duplicateChannelProfile(
profile.id,
trimmedName
);
if (duplicatedProfile) {
notifications.show({
title: 'Profile duplicated',
message: `${profile.name} copied to ${duplicatedProfile.name}`,
color: 'green.5',
});
setSelectedProfileId(`${duplicatedProfile.id}`);
closeModal();
}
}
};
return (
<Modal
opened={opened}
onClose={closeModal}
title={
mode === 'duplicate'
? `Duplicate Profile: ${profile?.name}`
: `Rename Profile: ${profile?.name}`
}
centered
size="sm"
>
<Stack gap="sm">
{mode === 'edit' && (
<Alert color="yellow" title="Warning">
<Text size="sm">
If you have any profile links (M3U, EPG, HDHR) shared with
clients, they will need to be updated after renaming this profile.
</Text>
</Alert>
)}
<TextInput
label="Profile name"
placeholder="Profile name"
value={profileNameInput}
onChange={(event) => setProfileNameInput(event.currentTarget.value)}
data-autofocus
/>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={closeModal}>
Cancel
</Button>
<Button size="xs" onClick={submitProfileModal}>
{mode === 'duplicate' ? 'Duplicate' : 'Save'}
</Button>
</Group>
</Stack>
</Modal>
);
};
export const renderProfileOption = (
theme,
profiles,
onEditProfile,
onDeleteProfile,
authUser
) => {
return ({ option }) => {
return (
<Group justify="space-between" style={{ width: '100%' }}>
<Box>{option.label}</Box>
{option.value != '0' && (
<Group gap={4} wrap="nowrap">
<Tooltip label="Rename profile">
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={(e) => {
e.stopPropagation();
onEditProfile('edit', option.value);
}}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquarePen size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Duplicate profile">
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.green[5]}
onClick={(e) => {
e.stopPropagation();
onEditProfile('duplicate', option.value);
}}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<Copy size={14} />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.red[6]}
onClick={(e) => {
e.stopPropagation();
onDeleteProfile(option.value);
}}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquareMinus />
</ActionIcon>
</Group>
)}
</Group>
);
};
};
export default ProfileModal;

View file

@ -38,6 +38,7 @@ import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
import GroupManager from '../../forms/GroupManager';
import ConfirmationDialog from '../../ConfirmationDialog';
import useWarningsStore from '../../../store/warnings';
import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
@ -117,6 +118,11 @@ const ChannelTableHeader = ({
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] =
useState(false);
const [profileToDelete, setProfileToDelete] = useState(null);
const [profileModalState, setProfileModalState] = useState({
opened: false,
mode: null,
profileId: null,
});
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
@ -128,6 +134,15 @@ const ChannelTableHeader = ({
setAssignNumbersModalOpen(false);
};
const closeProfileModal = () => {
setProfileModalState({ opened: false, mode: null, profileId: null });
};
const openProfileModal = (mode, profileId) => {
if (!profiles[profileId]) return;
setProfileModalState({ opened: true, mode, profileId });
};
const deleteProfile = async (id) => {
// Get profile details for the confirmation dialog
const profileObj = profiles[id];
@ -192,27 +207,13 @@ const ChannelTableHeader = ({
}
};
const renderProfileOption = ({ option, checked }) => {
return (
<Group justify="space-between" style={{ width: '100%' }}>
<Box>{option.label}</Box>
{option.value != '0' && (
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.red[6]}
onClick={(e) => {
e.stopPropagation();
deleteProfile(option.value);
}}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquareMinus />
</ActionIcon>
)}
</Group>
);
};
const renderModalOption = renderProfileOption(
theme,
profiles,
openProfileModal,
deleteProfile,
authUser
);
const toggleShowDisabled = () => {
setShowDisabled(!showDisabled);
@ -234,7 +235,8 @@ const ChannelTableHeader = ({
label: profile.name,
value: `${profile.id}`,
}))}
renderOption={renderProfileOption}
renderOption={renderModalOption}
style={{ minWidth: 190 }}
/>
<Tooltip label="Create Profile">
@ -373,6 +375,18 @@ const ChannelTableHeader = ({
</Flex>
</Box>
<ProfileModal
opened={profileModalState.opened}
onClose={closeProfileModal}
mode={profileModalState.mode}
profile={
profileModalState.profileId
? profiles[profileModalState.profileId]
: null
}
onDeleteProfile={deleteProfile}
/>
<AssignChannelNumbersForm
channelIds={selectedTableIds}
isOpen={assignNumbersModalOpen}