diff --git a/apps/output/views.py b/apps/output/views.py index 9fb481cb..c51e4472 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -14,20 +14,34 @@ import json from urllib.parse import urlparse -def generate_m3u(request, user): - if user.user_level == 0: - channel_profiles = user.channel_profiles.all() - filters = { - "channelprofilemembership__channel_profile__in": channel_profiles, - "channelprofilemembership__enabled": True, - "user_level__lte": user.user_level, - } +def generate_m3u(request, profile_name=None, user=None): + """ + Dynamically generate an M3U file from channels. + The stream URL now points to the new stream_view that uses StreamProfile. + """ + if user is not None: + if user.user_level == 0: + channel_profiles = user.channel_profiles.all() + filters = { + "channelprofilemembership__channel_profile__in": channel_profiles, + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + } - channels = Channel.objects.filter(**filters).order_by("channel_number") + channels = Channel.objects.filter(**filters).order_by("channel_number") + else: + channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( + "channel_number" + ) else: - channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( - "channel_number" - ) + if profile_name is not None: + channel_profile = ChannelProfile.objects.get(name=profile_name) + channels = Channel.objects.filter( + channelprofilemembership__channel_profile=channel_profile, + channelprofilemembership__enabled=True, + ).order_by("channel_number") + else: + channels = Channel.objects.order_by("channel_number") m3u_content = "#EXTM3U\n" for channel in channels: @@ -177,7 +191,7 @@ def generate_dummy_epg( return xml_lines -def generate_epg(request, user): +def generate_epg(request, profile_name=None, user=None): """ Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models. Since the EPG data is stored independently of Channels, we group programmes @@ -190,19 +204,29 @@ def generate_epg(request, user): '' ) - if user.user_level == 0: - channel_profiles = user.channel_profiles.all() - filters = { - "channelprofilemembership__channel_profile__in": channel_profiles, - "channelprofilemembership__enabled": True, - "user_level__lte": user.user_level, - } + if user is not None: + if user.user_level == 0: + channel_profiles = user.channel_profiles.all() + filters = { + "channelprofilemembership__channel_profile__in": channel_profiles, + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + } - channels = Channel.objects.filter(**filters).order_by("channel_number") + channels = Channel.objects.filter(**filters).order_by("channel_number") + else: + channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( + "channel_number" + ) else: - channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( - "channel_number" - ) + if profile_name is not None: + channel_profile = ChannelProfile.objects.get(name=profile_name) + channels = Channel.objects.filter( + channelprofilemembership__channel_profile=channel_profile, + channelprofilemembership__enabled=True, + ) + else: + channels = Channel.objects.all() # Retrieve all active channels for channel in channels: diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index afbb3c3f..83bc2fc3 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -33,6 +33,7 @@ import useSettingsStore from '../store/settings'; import useAuthStore from '../store/auth'; // Add this import import API from '../api'; import { USER_LEVELS } from '../constants'; +import UserForm from './forms/User'; const NavLink = ({ item, isActive, collapsed }) => { return ( @@ -81,6 +82,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { version: '', timestamp: null, }); + const [userFormOpen, setUserFormOpen] = useState(false); + + const closeUserForm = () => setUserFormOpen(false); // Navigation Items const navItems = @@ -295,9 +299,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { whiteSpace: 'nowrap', }} > - + setUserFormOpen(true)}> {authUser.username} - + @@ -315,6 +319,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''} )} + + ); }; diff --git a/frontend/src/components/forms/AssignChannelNumbers.jsx b/frontend/src/components/forms/AssignChannelNumbers.jsx new file mode 100644 index 00000000..7465cae7 --- /dev/null +++ b/frontend/src/components/forms/AssignChannelNumbers.jsx @@ -0,0 +1,88 @@ +import React, { useState, useEffect, useRef } from 'react'; +import API from '../../api'; +import { + Button, + Modal, + Text, + Group, + Flex, + useMantineTheme, + NumberInput, +} from '@mantine/core'; +import { ListOrdered } from 'lucide-react'; +import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; + +const AssignChannelNumbers = ({ channelIds, isOpen, onClose }) => { + const theme = useMantineTheme(); + + const form = useForm({ + mode: 'uncontrolled', + initialValues: { + starting_number: 1, + }, + }); + + const onSubmit = async () => { + const { starting_number } = form.getValues(); + + try { + const result = await API.assignChannelNumbers( + channelIds, + starting_number + ); + + notifications.show({ + title: result.message || 'Channels assigned', + color: 'green.5', + }); + + API.requeryChannels(); + + onClose(); + } catch (err) { + console.error(err); + notifications.show({ + title: 'Failed to assign channels', + color: 'red.5', + }); + } + }; + + if (!isOpen) { + return <>; + } + + return ( + + + Assign Channel #s + + } + styles={{ hannontent: { '--mantine-color-body': '#27272A' } }} + > +
+ + + + + + +
+ ); +}; + +export default AssignChannelNumbers; diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx new file mode 100644 index 00000000..12ee52b8 --- /dev/null +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -0,0 +1,294 @@ +import React, { useState, useEffect, useRef } from 'react'; +import useChannelsStore from '../../store/channels'; +import API from '../../api'; +import useStreamProfilesStore from '../../store/streamProfiles'; +import ChannelGroupForm from './ChannelGroup'; +import { + Box, + Button, + Modal, + TextInput, + Text, + Group, + ActionIcon, + Flex, + Select, + Stack, + useMantineTheme, + Popover, + ScrollArea, + Tooltip, + UnstyledButton, + Center, +} from '@mantine/core'; +import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react'; +import { FixedSizeList as List } from 'react-window'; +import { useForm } from '@mantine/form'; +import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; + +const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { + const theme = useMantineTheme(); + + const groupListRef = useRef(null); + + const channelGroups = useChannelsStore((s) => s.channelGroups); + const streamProfiles = useStreamProfilesStore((s) => s.profiles); + + const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); + const [selectedChannelGroup, setSelectedChannelGroup] = useState(''); + + const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); + const [groupFilter, setGroupFilter] = useState(''); + const groupOptions = Object.values(channelGroups); + + const form = useForm({ + mode: 'uncontrolled', + initialValues: { + channel_group: '', + stream_profile_id: '0', + user_level: '-1', + }, + }); + + const onSubmit = async () => { + const values = { + ...form.getValues(), + channel_group_id: selectedChannelGroup, + }; + + if (!values.stream_profile_id || values.stream_profile_id === '0') { + values.stream_profile_id = null; + } + + if (!values.channel_group_id) { + delete values.channel_group_id; + } + + if (values.user_level == '-1') { + delete values.user_level; + } + + await API.batchUpdateChannels({ + ids: channelIds, + values, + }); + }; + + // useEffect(() => { + // // const sameStreamProfile = channels.every( + // // (channel) => channel.stream_profile_id == channels[0].stream_profile_id + // // ); + // // const sameChannelGroup = channels.every( + // // (channel) => channel.channel_group_id == channels[0].channel_group_id + // // ); + // // const sameUserLevel = channels.every( + // // (channel) => channel.user_level == channels[0].user_level + // // ); + // // form.setValues({ + // // ...(sameStreamProfile && { + // // stream_profile_id: `${channels[0].stream_profile_id}`, + // // }), + // // ...(sameChannelGroup && { + // // channel_group_id: `${channels[0].channel_group_id}`, + // // }), + // // ...(sameUserLevel && { + // // user_level: `${channels[0].user_level}`, + // // }), + // // }); + // }, [channelIds, streamProfiles, channelGroups]); + + const handleChannelGroupModalClose = (newGroup) => { + setChannelGroupModalOpen(false); + + if (newGroup && newGroup.id) { + setSelectedChannelGroup(newGroup.id); + form.setValues({ + channel_group: `${newGroup.name}`, + }); + } + }; + + const filteredGroups = groupOptions.filter((group) => + group.name.toLowerCase().includes(groupFilter.toLowerCase()) + ); + + if (!isOpen) { + return <>; + } + + return ( + <> + + + Channels + + } + styles={{ hannontent: { '--mantine-color-body': '#27272A' } }} + > +
+ + + + + + setGroupPopoverOpened(true)} + size="xs" + style={{ flex: 1 }} + /> + + setChannelGroupModalOpen(true)} + title="Create new group" + size="small" + variant="transparent" + style={{ marginBottom: 5 }} + > + + + + + + e.stopPropagation()}> + + + setGroupFilter(event.currentTarget.value) + } + mb="xs" + size="xs" + style={{ flex: 1 }} + /> + + setChannelGroupModalOpen(true)} + title="Create new group" + size="small" + variant="transparent" + style={{ marginBottom: 5 }} + > + + + + + + + {({ index, style }) => ( + + + { + setSelectedChannelGroup( + filteredGroups[index].id + ); + form.setValues({ + channel_group: filteredGroups[index].name, + }); + setGroupPopoverOpened(false); + }} + > + + {filteredGroups[index].name} + + + + + )} + + + + + + { + return { + label: USER_LEVEL_LABELS[value], + value: `${value}`, + }; + }) + )} + /> + + + + + + +
+
+ + + + ); +}; + +export default ChannelBatchForm; diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 65669ce9..5a483fbd 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -20,12 +20,13 @@ import { MultiSelect, } from '@mantine/core'; import { isNotEmpty, useForm } from '@mantine/form'; -import useUsersStore from '../../store/users'; import useChannelsStore from '../../store/channels'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; +import useAuthStore from '../../store/auth'; const User = ({ user = null, isOpen, onClose }) => { const profiles = useChannelsStore((s) => s.profiles); + const authUser = useAuthStore((s) => s.user); console.log(user); @@ -77,7 +78,7 @@ const User = ({ user = null, isOpen, onClose }) => { }; useEffect(() => { - if (user) { + if (user?.id) { form.setValues({ username: user.username, email: user.email, @@ -93,10 +94,16 @@ const User = ({ user = null, isOpen, onClose }) => { return <>; } - console.log(user); + const showPermissions = + authUser.user_level == USER_LEVELS.ADMIN && authUser.id !== user?.id; return ( - +
@@ -123,31 +130,33 @@ const User = ({ user = null, isOpen, onClose }) => { /> - - { + return { + label: USER_LEVEL_LABELS[value], + value: `${value}`, + }; + })} + {...form.getInputProps('user_level')} + key={form.key('user_level')} + /> - profile.id != 0) - .map((profile) => ({ - label: profile.name, - value: `${profile.id}`, - }))} - /> - + profile.id != 0) + .map((profile) => ({ + label: profile.name, + value: `${profile.id}`, + }))} + /> + + )} diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 77c9d6a5..6e39de37 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -3,6 +3,7 @@ import useChannelsStore from '../../store/channels'; import { notifications } from '@mantine/notifications'; import API from '../../api'; import ChannelForm from '../forms/Channel'; +import ChannelBatchForm from '../forms/ChannelBatch'; import RecordingForm from '../forms/Recording'; import { useDebounce, copyToClipboard } from '../../utils'; import logo from '../../images/logo.png'; @@ -268,6 +269,7 @@ const ChannelsTable = ({}) => { */ const [channel, setChannel] = useState(null); const [channelModalOpen, setChannelModalOpen] = useState(false); + const [channelBatchModalOpen, setChannelBatchModalOpen] = useState(false); const [recordingModalOpen, setRecordingModalOpen] = useState(false); const [selectedProfile, setSelectedProfile] = useState( profiles[selectedProfileId] @@ -365,8 +367,12 @@ const ChannelsTable = ({}) => { }; const editChannel = async (ch = null) => { - setChannel(ch); - setChannelModalOpen(true); + if (selectedChannelIds.length > 0) { + setChannelBatchModalOpen(true); + } else { + setChannel(ch); + setChannelModalOpen(true); + } }; const deleteChannel = async (id) => { @@ -480,6 +486,10 @@ const ChannelsTable = ({}) => { }); }; + const closeChannelBatchForm = () => { + setChannelBatchModalOpen(false); + }; + const closeChannelForm = () => { setChannel(null); setChannelModalOpen(false); @@ -1041,6 +1051,12 @@ const ChannelsTable = ({}) => { onClose={closeChannelForm} /> + + { const [opened, setOpened] = useState(false); @@ -96,12 +102,17 @@ const ChannelTableHeader = ({ const theme = useMantineTheme(); const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); + const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId); const authUser = useAuthStore((s) => s.user); + const closeAssignChannelNumbersModal = () => { + setAssignNumbersModalOpen(false); + }; + const deleteProfile = async (id) => { await API.deleteChannelProfile(id); }; @@ -195,6 +206,19 @@ const ChannelTableHeader = ({ }} > + + - - - - - - - - Start # - - - - - - - - - - - - - + + + + + + + + + + } + disabled={ + selectedTableIds.length == 0 || + authUser.user_level != USER_LEVELS.ADMIN + } + > + setAssignNumbersModalOpen(true)} + > + Assign #s + + + + } + disabled={authUser.user_level != USER_LEVELS.ADMIN} + > + + Auto-Match + + + + + + ); }; diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx index 60ca9702..52e79869 100644 --- a/frontend/src/pages/Users.jsx +++ b/frontend/src/pages/Users.jsx @@ -5,9 +5,12 @@ import { Box, Button, Center, + Divider, Group, Paper, Select, + Stack, + Text, useMantineTheme, } from '@mantine/core'; import { SquareMinus, SquarePen, SquarePlus } from 'lucide-react'; @@ -46,63 +49,74 @@ const UsersPage = () => {
- - {Object.values(users) - .sort((a, b) => a.id > b.id) - .map((user) => { - return ( - - - {user.username} - + + + + - - {user.email} - + {users + .sort((a, b) => a.id > b.id) + .map((user) => { + if (!user) { + return <>; + } - {authUser.user_level == USER_LEVELS.ADMIN && ( - - editUser(user)} - > - - + return ( + + + {user.username} + - deleteUser(user.id)} - disabled={authUser.id === user.id} - > - - - - )} - - ); - })} + + {user.email} + + + {authUser.user_level == USER_LEVELS.ADMIN && ( + + {USER_LEVEL_LABELS[user.user_level]} + editUser(user)} + > + + + + deleteUser(user.id)} + disabled={authUser.id === user.id} + > + + + + )} + + ); + })} +
diff --git a/frontend/src/store/users.jsx b/frontend/src/store/users.jsx index 1b219a46..1ca4c7e0 100644 --- a/frontend/src/store/users.jsx +++ b/frontend/src/store/users.jsx @@ -2,7 +2,7 @@ import { create } from 'zustand'; import api from '../api'; const useUsersStore = create((set) => ({ - users: {}, + users: [], isLoading: false, error: null,