diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae520e63..60eb57cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 59f03baf..41f575e6 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -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]]
diff --git a/frontend/src/api.js b/frontend/src/api.js
index c96b3cb8..c33ff1ee 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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}/`, {
diff --git a/frontend/src/components/modals/ProfileModal.jsx b/frontend/src/components/modals/ProfileModal.jsx
new file mode 100644
index 00000000..069457bb
--- /dev/null
+++ b/frontend/src/components/modals/ProfileModal.jsx
@@ -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 (
+
+
+ {mode === 'edit' && (
+
+
+ If you have any profile links (M3U, EPG, HDHR) shared with
+ clients, they will need to be updated after renaming this profile.
+
+
+ )}
+ setProfileNameInput(event.currentTarget.value)}
+ data-autofocus
+ />
+
+
+
+
+
+
+
+ );
+};
+
+export const renderProfileOption = (
+ theme,
+ profiles,
+ onEditProfile,
+ onDeleteProfile,
+ authUser
+) => {
+ return ({ option }) => {
+ return (
+
+ {option.label}
+ {option.value != '0' && (
+
+
+ {
+ e.stopPropagation();
+ onEditProfile('edit', option.value);
+ }}
+ disabled={authUser.user_level != USER_LEVELS.ADMIN}
+ >
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onEditProfile('duplicate', option.value);
+ }}
+ disabled={authUser.user_level != USER_LEVELS.ADMIN}
+ >
+
+
+
+
+ {
+ e.stopPropagation();
+ onDeleteProfile(option.value);
+ }}
+ disabled={authUser.user_level != USER_LEVELS.ADMIN}
+ >
+
+
+
+ )}
+
+ );
+ };
+};
+
+export default ProfileModal;
diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
index 460ab12a..de389ccb 100644
--- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
+++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
@@ -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 (
-
- {option.label}
- {option.value != '0' && (
- {
- e.stopPropagation();
- deleteProfile(option.value);
- }}
- disabled={authUser.user_level != USER_LEVELS.ADMIN}
- >
-
-
- )}
-
- );
- };
+ 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 }}
/>
@@ -373,6 +375,18 @@ const ChannelTableHeader = ({
+
+