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 = ({ + +