mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
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:
parent
564dceb210
commit
f821dabe8e
5 changed files with 299 additions and 23 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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}/`, {
|
||||
|
|
|
|||
193
frontend/src/components/modals/ProfileModal.jsx
Normal file
193
frontend/src/components/modals/ProfileModal.jsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue